1use std::collections::HashMap;
12use std::path::Path;
13
14use regex::Regex;
15use serde_json::{json, Value};
16use tracing::{debug, info, warn};
17
18use crate::types::ScannedModule;
19
20const MCP_ALIAS_MAX: usize = 64;
21
22#[derive(Debug, Default)]
34pub struct DisplayResolver;
35
36impl DisplayResolver {
37 pub fn new() -> Self {
39 Self
40 }
41
42 pub fn resolve(
58 &self,
59 modules: Vec<ScannedModule>,
60 binding_path: Option<&Path>,
61 binding_data: Option<&Value>,
62 ) -> Result<Vec<ScannedModule>, DisplayResolverError> {
63 let binding_map = self.build_binding_map(binding_path, binding_data);
64
65 if !binding_map.is_empty() {
66 let matched = modules
67 .iter()
68 .filter(|m| binding_map.contains_key(&m.module_id))
69 .count();
70 info!(
71 "DisplayResolver: {}/{} modules matched binding entries.",
72 matched,
73 modules.len(),
74 );
75 if matched == 0 {
76 warn!(
77 "DisplayResolver: binding map loaded {} entries but none matched \
78 any scanned module_id — check binding.yaml module_id values.",
79 binding_map.len(),
80 );
81 }
82 }
83
84 modules
85 .into_iter()
86 .map(|m| self.resolve_one(m, &binding_map))
87 .collect()
88 }
89
90 fn build_binding_map(
96 &self,
97 binding_path: Option<&Path>,
98 binding_data: Option<&Value>,
99 ) -> HashMap<String, Value> {
100 if let Some(data) = binding_data {
101 return Self::parse_binding_data(data);
102 }
103 if let Some(path) = binding_path {
104 return self.load_binding_files(path);
105 }
106 HashMap::new()
107 }
108
109 fn parse_binding_data(data: &Value) -> HashMap<String, Value> {
113 let mut result = HashMap::new();
114
115 if let Some(bindings) = data.get("bindings").and_then(|v| v.as_array()) {
117 for entry in bindings {
118 if let Some(module_id) = entry.get("module_id").and_then(|v| v.as_str()) {
119 result.insert(module_id.to_string(), entry.clone());
120 }
121 }
122 return result;
123 }
124
125 if let Some(obj) = data.as_object() {
127 for (k, v) in obj {
128 if v.is_object() {
129 result.insert(k.clone(), v.clone());
130 }
131 }
132 }
133
134 result
135 }
136
137 fn load_binding_files(&self, path: &Path) -> HashMap<String, Value> {
139 let mut result = HashMap::new();
140
141 let files: Vec<std::path::PathBuf> = if path.is_file() {
142 vec![path.to_path_buf()]
143 } else if path.is_dir() {
144 let mut entries: Vec<std::path::PathBuf> = std::fs::read_dir(path)
145 .ok()
146 .map(|rd| {
147 rd.filter_map(|e| e.ok())
148 .map(|e| e.path())
149 .filter(|p| {
150 p.file_name()
151 .and_then(|n| n.to_str())
152 .is_some_and(|n| n.ends_with(".binding.yaml"))
153 })
154 .collect()
155 })
156 .unwrap_or_default();
157 entries.sort();
158 entries
159 } else {
160 warn!("DisplayResolver: binding path not found: {:?}", path);
161 return result;
162 };
163
164 for f in files {
165 match std::fs::read_to_string(&f) {
166 Ok(content) => match serde_yaml::from_str::<Value>(&content) {
167 Ok(data) => {
168 let parsed = Self::parse_binding_data(&data);
169 result.extend(parsed);
170 }
171 Err(e) => {
172 warn!("DisplayResolver: failed to parse {:?}: {}", f, e);
173 }
174 },
175 Err(e) => {
176 warn!("DisplayResolver: failed to load {:?}: {}", f, e);
177 }
178 }
179 }
180
181 result
182 }
183
184 fn resolve_one(
186 &self,
187 mut module: ScannedModule,
188 binding_map: &HashMap<String, Value>,
189 ) -> Result<ScannedModule, DisplayResolverError> {
190 let empty_obj = json!({});
191 let entry = binding_map.get(&module.module_id).unwrap_or(&empty_obj);
192 let display_cfg = entry.get("display").unwrap_or(&empty_obj);
193 let binding_desc = entry.get("description").and_then(|v| v.as_str());
194 let binding_docs = entry.get("documentation").and_then(|v| v.as_str());
195
196 let suggested_alias = module
197 .metadata
198 .get("suggested_alias")
199 .and_then(|v| v.as_str())
200 .map(|s| s.to_string());
201
202 let default_alias = str_or(display_cfg, "alias")
204 .or(suggested_alias.as_deref())
205 .unwrap_or(&module.module_id)
206 .to_string();
207
208 let default_description = str_or(display_cfg, "description")
209 .or(binding_desc)
210 .unwrap_or(&module.description)
211 .to_string();
212
213 let default_documentation = str_or(display_cfg, "documentation")
214 .or(binding_docs)
215 .or(module.documentation.as_deref())
216 .map(|s| s.to_string());
217
218 let default_guidance = str_or(display_cfg, "guidance").map(|s| s.to_string());
219
220 let resolved_tags = tags_or(display_cfg, "tags")
221 .or_else(|| tags_or(entry, "tags"))
222 .unwrap_or_else(|| module.tags.clone());
223
224 let (cli_surface, cli_alias_explicit) = self.resolve_surface(
226 display_cfg,
227 "cli",
228 &default_alias,
229 &default_description,
230 &default_guidance,
231 );
232 let (mcp_surface, _) = self.resolve_surface(
233 display_cfg,
234 "mcp",
235 &default_alias,
236 &default_description,
237 &default_guidance,
238 );
239 let (a2a_surface, _) = self.resolve_surface(
240 display_cfg,
241 "a2a",
242 &default_alias,
243 &default_description,
244 &default_guidance,
245 );
246
247 let mut mcp_surface = mcp_surface;
249 let raw_mcp_alias = mcp_surface
250 .get("alias")
251 .and_then(|v| v.as_str())
252 .unwrap_or("")
253 .to_string();
254
255 let mcp_alias_re = Regex::new(r"[^a-zA-Z0-9_-]").unwrap();
256 let mut sanitized = mcp_alias_re.replace_all(&raw_mcp_alias, "_").to_string();
257 if sanitized.starts_with(|c: char| c.is_ascii_digit()) {
258 sanitized = format!("_{sanitized}");
259 }
260 mcp_surface["alias"] = json!(sanitized);
261
262 if sanitized != raw_mcp_alias {
263 debug!(
264 "Module '{}': MCP alias auto-sanitized '{}' → '{}'.",
265 module.module_id, raw_mcp_alias, sanitized,
266 );
267 }
268
269 let mut display = json!({
270 "alias": default_alias,
271 "description": default_description,
272 "guidance": default_guidance,
273 "tags": resolved_tags,
274 "cli": cli_surface,
275 "mcp": mcp_surface,
276 "a2a": a2a_surface,
277 });
278
279 if let Some(doc) = &default_documentation {
280 display["documentation"] = json!(doc);
281 } else {
282 display["documentation"] = Value::Null;
283 }
284
285 self.validate_aliases(&mut display, &module.module_id, cli_alias_explicit)?;
287
288 module.metadata.insert("display".into(), display);
289 Ok(module)
290 }
291
292 fn resolve_surface(
296 &self,
297 display_cfg: &Value,
298 key: &str,
299 default_alias: &str,
300 default_description: &str,
301 default_guidance: &Option<String>,
302 ) -> (Value, bool) {
303 let empty = json!({});
304 let sc = display_cfg.get(key).unwrap_or(&empty);
305 let alias_explicit = sc.get("alias").and_then(|v| v.as_str()).is_some();
306
307 let alias = str_or(sc, "alias").unwrap_or(default_alias);
308 let description = str_or(sc, "description").unwrap_or(default_description);
309 let guidance = str_or(sc, "guidance")
310 .map(|s| s.to_string())
311 .or_else(|| default_guidance.clone());
312
313 let mut surface = json!({
314 "alias": alias,
315 "description": description,
316 });
317 if let Some(g) = &guidance {
318 surface["guidance"] = json!(g);
319 } else {
320 surface["guidance"] = Value::Null;
321 }
322
323 (surface, alias_explicit)
324 }
325
326 fn validate_aliases(
328 &self,
329 display: &mut Value,
330 module_id: &str,
331 cli_alias_explicit: bool,
332 ) -> Result<(), DisplayResolverError> {
333 let mcp_alias_pattern = Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_-]*$").unwrap();
334 let cli_alias_pattern = Regex::new(r"^[a-z][a-z0-9_-]*$").unwrap();
335
336 let mcp_alias = display["mcp"]["alias"].as_str().unwrap_or("").to_string();
338
339 if mcp_alias.len() > MCP_ALIAS_MAX {
340 return Err(DisplayResolverError::Validation(format!(
341 "Module '{}': MCP alias '{}' exceeds {}-character hard limit (OpenAI spec). \
342 Set display.mcp.alias to a shorter value.",
343 module_id, mcp_alias, MCP_ALIAS_MAX,
344 )));
345 }
346 if !mcp_alias_pattern.is_match(&mcp_alias) {
347 return Err(DisplayResolverError::Validation(format!(
348 "Module '{}': MCP alias '{}' does not match \
349 required pattern ^[a-zA-Z_][a-zA-Z0-9_-]*$.",
350 module_id, mcp_alias,
351 )));
352 }
353
354 if cli_alias_explicit {
356 let cli_alias = display["cli"]["alias"].as_str().unwrap_or("").to_string();
357 if !cli_alias_pattern.is_match(&cli_alias) {
358 let default_alias = display["alias"].as_str().unwrap_or("").to_string();
359 warn!(
360 "Module '{}': CLI alias '{}' does not match shell-safe pattern \
361 ^[a-z][a-z0-9_-]*$ — falling back to default alias '{}'.",
362 module_id, cli_alias, default_alias,
363 );
364 display["cli"]["alias"] = json!(default_alias);
365 }
366 }
367
368 Ok(())
369 }
370}
371
372#[derive(Debug, thiserror::Error)]
374pub enum DisplayResolverError {
375 #[error("{0}")]
377 Validation(String),
378}
379
380fn str_or<'a>(val: &'a Value, key: &str) -> Option<&'a str> {
384 val.get(key)
385 .and_then(|v| v.as_str())
386 .filter(|s| !s.is_empty())
387}
388
389fn tags_or(val: &Value, key: &str) -> Option<Vec<String>> {
391 val.get(key).and_then(|v| v.as_array()).map(|arr| {
392 arr.iter()
393 .filter_map(|v| v.as_str().map(|s| s.to_string()))
394 .collect()
395 })
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use serde_json::json;
402
403 fn make_module(module_id: &str, description: &str) -> ScannedModule {
405 ScannedModule::new(
406 module_id.into(),
407 description.into(),
408 json!({"type": "object"}),
409 json!({"type": "object"}),
410 vec!["default-tag".into()],
411 format!("app:{module_id}"),
412 )
413 }
414
415 #[test]
416 fn test_new_creates_default_instance() {
417 let resolver = DisplayResolver::new();
418 let _ = format!("{:?}", resolver);
420 }
421
422 #[test]
423 fn test_resolve_passthrough_no_bindings() {
424 let resolver = DisplayResolver::new();
425 let modules = vec![make_module("users.get", "Get a user")];
426 let resolved = resolver.resolve(modules, None, None).unwrap();
427
428 assert_eq!(resolved.len(), 1);
429 let display = resolved[0].metadata.get("display").unwrap();
430 assert_eq!(display["alias"], "users.get");
432 assert_eq!(display["description"], "Get a user");
433 }
434
435 #[test]
436 fn test_resolve_with_binding_data_map_format() {
437 let resolver = DisplayResolver::new();
438 let modules = vec![make_module("users.get", "Get a user")];
439
440 let binding_data = json!({
441 "users.get": {
442 "display": {
443 "alias": "get-user",
444 "description": "Retrieve a user by ID",
445 "guidance": "Use when you know the user ID"
446 }
447 }
448 });
449
450 let resolved = resolver
451 .resolve(modules, None, Some(&binding_data))
452 .unwrap();
453 let display = resolved[0].metadata.get("display").unwrap();
454
455 assert_eq!(display["alias"], "get-user");
456 assert_eq!(display["description"], "Retrieve a user by ID");
457 assert_eq!(display["guidance"], "Use when you know the user ID");
458 }
459
460 #[test]
461 fn test_resolve_with_binding_data_bindings_list_format() {
462 let resolver = DisplayResolver::new();
463 let modules = vec![make_module("users.get", "Get a user")];
464
465 let binding_data = json!({
466 "bindings": [
467 {
468 "module_id": "users.get",
469 "description": "Binding-level desc",
470 "display": {
471 "alias": "get-user"
472 }
473 }
474 ]
475 });
476
477 let resolved = resolver
478 .resolve(modules, None, Some(&binding_data))
479 .unwrap();
480 let display = resolved[0].metadata.get("display").unwrap();
481
482 assert_eq!(display["alias"], "get-user");
483 assert_eq!(display["description"], "Binding-level desc");
485 }
486
487 #[test]
488 fn test_resolution_chain_precedence() {
489 let resolver = DisplayResolver::new();
491 let modules = vec![make_module("users.get", "Scanner desc")];
492
493 let binding_data = json!({
494 "users.get": {
495 "description": "Binding desc",
496 "display": {
497 "description": "Display desc",
498 "cli": {
499 "description": "CLI desc"
500 }
501 }
502 }
503 });
504
505 let resolved = resolver
506 .resolve(modules, None, Some(&binding_data))
507 .unwrap();
508 let display = resolved[0].metadata.get("display").unwrap();
509
510 assert_eq!(display["description"], "Display desc");
512 assert_eq!(display["cli"]["description"], "CLI desc");
514 assert_eq!(display["mcp"]["description"], "Display desc");
516 }
517
518 #[test]
519 fn test_mcp_alias_auto_sanitization_dots() {
520 let resolver = DisplayResolver::new();
521 let modules = vec![make_module("image.resize", "Resize image")];
522
523 let resolved = resolver.resolve(modules, None, None).unwrap();
524 let display = resolved[0].metadata.get("display").unwrap();
525
526 assert_eq!(display["mcp"]["alias"], "image_resize");
528 }
529
530 #[test]
531 fn test_mcp_alias_auto_sanitization_spaces() {
532 let resolver = DisplayResolver::new();
533 let modules = vec![make_module("users.get user", "Get user")];
534
535 let resolved = resolver.resolve(modules, None, None).unwrap();
536 let display = resolved[0].metadata.get("display").unwrap();
537
538 assert_eq!(display["mcp"]["alias"], "users_get_user");
539 }
540
541 #[test]
542 fn test_mcp_alias_leading_digit_prefix() {
543 let resolver = DisplayResolver::new();
544 let binding_data = json!({
545 "test": {
546 "display": {
547 "alias": "1get-user"
548 }
549 }
550 });
551 let modules = vec![make_module("test", "Test")];
552
553 let resolved = resolver
554 .resolve(modules, None, Some(&binding_data))
555 .unwrap();
556 let display = resolved[0].metadata.get("display").unwrap();
557
558 assert_eq!(display["mcp"]["alias"], "_1get-user");
559 }
560
561 #[test]
562 fn test_mcp_alias_exceeds_max_length() {
563 let resolver = DisplayResolver::new();
564 let long_alias = "a".repeat(65);
565 let binding_data = json!({
566 "test": {
567 "display": {
568 "alias": long_alias
569 }
570 }
571 });
572 let modules = vec![make_module("test", "Test")];
573
574 let result = resolver.resolve(modules, None, Some(&binding_data));
575 assert!(result.is_err());
576 let err = result.unwrap_err();
577 assert!(err.to_string().contains("exceeds 64-character hard limit"));
578 }
579
580 #[test]
581 fn test_mcp_alias_invalid_pattern() {
582 let resolver = DisplayResolver::new();
583 let modules = vec![make_module("test", "Test")];
584
585 let binding_data2 = json!({
587 "test": {
588 "display": {
589 "mcp": {
590 "alias": "---invalid"
591 }
592 }
593 }
594 });
595 let result = resolver.resolve(modules, None, Some(&binding_data2));
596 assert!(result.is_err());
597 let err = result.unwrap_err();
598 assert!(err.to_string().contains("does not match"));
599 }
600
601 #[test]
602 fn test_cli_alias_explicit_invalid_falls_back() {
603 let resolver = DisplayResolver::new();
604 let binding_data = json!({
605 "users.get": {
606 "display": {
607 "alias": "get-user",
608 "cli": {
609 "alias": "Get-User"
610 }
611 }
612 }
613 });
614 let modules = vec![make_module("users.get", "Get user")];
615
616 let resolved = resolver
617 .resolve(modules, None, Some(&binding_data))
618 .unwrap();
619 let display = resolved[0].metadata.get("display").unwrap();
620
621 assert_eq!(display["cli"]["alias"], "get-user");
623 }
624
625 #[test]
626 fn test_cli_alias_non_explicit_not_validated() {
627 let resolver = DisplayResolver::new();
629 let modules = vec![make_module("MyModule", "Description")];
630
631 let resolved = resolver.resolve(modules, None, None).unwrap();
633 let display = resolved[0].metadata.get("display").unwrap();
634
635 assert_eq!(display["cli"]["alias"], "MyModule");
637 }
638
639 #[test]
640 fn test_suggested_alias_fallback() {
641 let resolver = DisplayResolver::new();
642 let mut module = make_module("users__get_user", "Get user");
643 module
644 .metadata
645 .insert("suggested_alias".into(), json!("get_user"));
646
647 let resolved = resolver.resolve(vec![module], None, None).unwrap();
648 let display = resolved[0].metadata.get("display").unwrap();
649
650 assert_eq!(display["alias"], "get_user");
652 }
653
654 #[test]
655 fn test_tags_resolution_from_display() {
656 let resolver = DisplayResolver::new();
657 let binding_data = json!({
658 "test": {
659 "tags": ["binding-tag"],
660 "display": {
661 "tags": ["display-tag"]
662 }
663 }
664 });
665 let modules = vec![make_module("test", "Test")];
666
667 let resolved = resolver
668 .resolve(modules, None, Some(&binding_data))
669 .unwrap();
670 let display = resolved[0].metadata.get("display").unwrap();
671
672 let tags: Vec<String> = display["tags"]
674 .as_array()
675 .unwrap()
676 .iter()
677 .map(|v| v.as_str().unwrap().to_string())
678 .collect();
679 assert_eq!(tags, vec!["display-tag"]);
680 }
681
682 #[test]
683 fn test_tags_resolution_from_binding_entry() {
684 let resolver = DisplayResolver::new();
685 let binding_data = json!({
686 "test": {
687 "tags": ["binding-tag"]
688 }
689 });
690 let modules = vec![make_module("test", "Test")];
691
692 let resolved = resolver
693 .resolve(modules, None, Some(&binding_data))
694 .unwrap();
695 let display = resolved[0].metadata.get("display").unwrap();
696
697 let tags: Vec<String> = display["tags"]
698 .as_array()
699 .unwrap()
700 .iter()
701 .map(|v| v.as_str().unwrap().to_string())
702 .collect();
703 assert_eq!(tags, vec!["binding-tag"]);
704 }
705
706 #[test]
707 fn test_tags_fallback_to_scanner() {
708 let resolver = DisplayResolver::new();
709 let modules = vec![make_module("test", "Test")];
710
711 let resolved = resolver.resolve(modules, None, None).unwrap();
712 let display = resolved[0].metadata.get("display").unwrap();
713
714 let tags: Vec<String> = display["tags"]
715 .as_array()
716 .unwrap()
717 .iter()
718 .map(|v| v.as_str().unwrap().to_string())
719 .collect();
720 assert_eq!(tags, vec!["default-tag"]);
721 }
722
723 #[test]
724 fn test_documentation_resolution() {
725 let resolver = DisplayResolver::new();
726 let mut module = make_module("test", "Test");
727 module.documentation = Some("Scanner docs".into());
728
729 let binding_data = json!({
730 "test": {
731 "documentation": "Binding docs",
732 "display": {
733 "documentation": "Display docs"
734 }
735 }
736 });
737
738 let resolved = resolver
739 .resolve(vec![module], None, Some(&binding_data))
740 .unwrap();
741 let display = resolved[0].metadata.get("display").unwrap();
742
743 assert_eq!(display["documentation"], "Display docs");
744 }
745
746 #[test]
747 fn test_documentation_fallback_to_binding() {
748 let resolver = DisplayResolver::new();
749 let mut module = make_module("test", "Test");
750 module.documentation = Some("Scanner docs".into());
751
752 let binding_data = json!({
753 "test": {
754 "documentation": "Binding docs"
755 }
756 });
757
758 let resolved = resolver
759 .resolve(vec![module], None, Some(&binding_data))
760 .unwrap();
761 let display = resolved[0].metadata.get("display").unwrap();
762
763 assert_eq!(display["documentation"], "Binding docs");
764 }
765
766 #[test]
767 fn test_documentation_fallback_to_scanner() {
768 let resolver = DisplayResolver::new();
769 let mut module = make_module("test", "Test");
770 module.documentation = Some("Scanner docs".into());
771
772 let resolved = resolver.resolve(vec![module], None, None).unwrap();
773 let display = resolved[0].metadata.get("display").unwrap();
774
775 assert_eq!(display["documentation"], "Scanner docs");
776 }
777
778 #[test]
779 fn test_multiple_modules() {
780 let resolver = DisplayResolver::new();
781 let modules = vec![
782 make_module("mod_a", "Module A"),
783 make_module("mod_b", "Module B"),
784 make_module("mod_c", "Module C"),
785 ];
786
787 let binding_data = json!({
788 "mod_a": {
789 "display": { "alias": "alias-a" }
790 },
791 "mod_c": {
792 "display": { "alias": "alias-c" }
793 }
794 });
795
796 let resolved = resolver
797 .resolve(modules, None, Some(&binding_data))
798 .unwrap();
799 assert_eq!(resolved.len(), 3);
800 assert_eq!(resolved[0].metadata["display"]["alias"], "alias-a");
801 assert_eq!(resolved[1].metadata["display"]["alias"], "mod_b");
802 assert_eq!(resolved[2].metadata["display"]["alias"], "alias-c");
803 }
804
805 #[test]
806 fn test_binding_map_zero_matches_still_resolves() {
807 let resolver = DisplayResolver::new();
808 let modules = vec![make_module("actual_id", "Description")];
809
810 let binding_data = json!({
811 "nonexistent_id": {
812 "display": { "alias": "nope" }
813 }
814 });
815
816 let resolved = resolver
818 .resolve(modules, None, Some(&binding_data))
819 .unwrap();
820 assert_eq!(resolved.len(), 1);
821 assert_eq!(resolved[0].metadata["display"]["alias"], "actual_id");
822 }
823
824 #[test]
825 fn test_parse_binding_data_bindings_list() {
826 let data = json!({
827 "bindings": [
828 { "module_id": "a", "description": "Module A" },
829 { "module_id": "b", "description": "Module B" },
830 { "description": "No ID — should be skipped" }
831 ]
832 });
833 let map = DisplayResolver::parse_binding_data(&data);
834 assert_eq!(map.len(), 2);
835 assert!(map.contains_key("a"));
836 assert!(map.contains_key("b"));
837 }
838
839 #[test]
840 fn test_parse_binding_data_map() {
841 let data = json!({
842 "a": { "display": { "alias": "alias-a" } },
843 "b": { "display": { "alias": "alias-b" } },
844 "scalar": "not-an-object"
845 });
846 let map = DisplayResolver::parse_binding_data(&data);
847 assert_eq!(map.len(), 2);
848 assert!(map.contains_key("a"));
849 assert!(map.contains_key("b"));
850 }
851
852 #[test]
853 fn test_load_binding_files_single_file() {
854 let resolver = DisplayResolver::new();
855 let dir = tempfile::tempdir().unwrap();
856 let file_path = dir.path().join("test.binding.yaml");
857 std::fs::write(
858 &file_path,
859 "bindings:\n - module_id: test\n description: From file\n",
860 )
861 .unwrap();
862
863 let map = resolver.load_binding_files(&file_path);
864 assert_eq!(map.len(), 1);
865 assert!(map.contains_key("test"));
866 }
867
868 #[test]
869 fn test_load_binding_files_directory() {
870 let resolver = DisplayResolver::new();
871 let dir = tempfile::tempdir().unwrap();
872
873 std::fs::write(
874 dir.path().join("a.binding.yaml"),
875 "bindings:\n - module_id: a\n description: A\n",
876 )
877 .unwrap();
878 std::fs::write(
879 dir.path().join("b.binding.yaml"),
880 "bindings:\n - module_id: b\n description: B\n",
881 )
882 .unwrap();
883 std::fs::write(dir.path().join("c.yaml"), "bindings:\n - module_id: c\n").unwrap();
885
886 let map = resolver.load_binding_files(dir.path());
887 assert_eq!(map.len(), 2);
888 assert!(map.contains_key("a"));
889 assert!(map.contains_key("b"));
890 assert!(!map.contains_key("c"));
891 }
892
893 #[test]
894 fn test_load_binding_files_nonexistent_path() {
895 let resolver = DisplayResolver::new();
896 let map = resolver.load_binding_files(Path::new("/nonexistent/path"));
897 assert!(map.is_empty());
898 }
899
900 #[test]
901 fn test_load_binding_files_invalid_yaml() {
902 let resolver = DisplayResolver::new();
903 let dir = tempfile::tempdir().unwrap();
904 let file_path = dir.path().join("bad.binding.yaml");
905 std::fs::write(&file_path, "{{{{not valid yaml").unwrap();
906
907 let map = resolver.load_binding_files(&file_path);
908 assert!(map.is_empty());
909 }
910
911 #[test]
912 fn test_surface_fields_populated() {
913 let resolver = DisplayResolver::new();
914 let modules = vec![make_module("test_mod", "Test desc")];
915
916 let resolved = resolver.resolve(modules, None, None).unwrap();
917 let display = resolved[0].metadata.get("display").unwrap();
918
919 assert!(display.get("cli").is_some());
921 assert!(display.get("mcp").is_some());
922 assert!(display.get("a2a").is_some());
923
924 for surface in &["cli", "mcp", "a2a"] {
926 assert!(display[surface].get("alias").is_some());
927 assert!(display[surface].get("description").is_some());
928 }
929 }
930
931 #[test]
932 fn test_mcp_alias_valid_stays_unchanged() {
933 let resolver = DisplayResolver::new();
934 let binding_data = json!({
935 "test": {
936 "display": {
937 "mcp": {
938 "alias": "valid_alias-123"
939 }
940 }
941 }
942 });
943 let modules = vec![make_module("test", "Test")];
944
945 let resolved = resolver
946 .resolve(modules, None, Some(&binding_data))
947 .unwrap();
948 let display = resolved[0].metadata.get("display").unwrap();
949
950 assert_eq!(display["mcp"]["alias"], "valid_alias-123");
951 }
952
953 #[test]
954 fn test_binding_data_takes_precedence_over_path() {
955 let resolver = DisplayResolver::new();
956 let dir = tempfile::tempdir().unwrap();
957 let file_path = dir.path().join("test.binding.yaml");
958 std::fs::write(
959 &file_path,
960 "bindings:\n - module_id: test\n display:\n alias: from-file\n",
961 )
962 .unwrap();
963
964 let binding_data = json!({
965 "test": {
966 "display": { "alias": "from-data" }
967 }
968 });
969
970 let modules = vec![make_module("test", "Test")];
971 let resolved = resolver
972 .resolve(modules, Some(file_path.as_path()), Some(&binding_data))
973 .unwrap();
974 let display = resolved[0].metadata.get("display").unwrap();
975
976 assert_eq!(display["alias"], "from-data");
978 }
979
980 #[test]
981 fn test_mcp_alias_64_chars_exactly_ok() {
982 let resolver = DisplayResolver::new();
983 let alias_64 = "a".repeat(64);
984 let binding_data = json!({
985 "test": {
986 "display": {
987 "alias": alias_64
988 }
989 }
990 });
991 let modules = vec![make_module("test", "Test")];
992
993 let result = resolver.resolve(modules, None, Some(&binding_data));
994 assert!(result.is_ok());
995 }
996
997 #[test]
998 fn test_empty_modules_list() {
999 let resolver = DisplayResolver::new();
1000 let resolved = resolver.resolve(vec![], None, None).unwrap();
1001 assert!(resolved.is_empty());
1002 }
1003
1004 #[test]
1005 fn test_original_metadata_preserved() {
1006 let resolver = DisplayResolver::new();
1007 let mut module = make_module("test", "Test");
1008 module
1009 .metadata
1010 .insert("custom_key".into(), json!("custom_value"));
1011
1012 let resolved = resolver.resolve(vec![module], None, None).unwrap();
1013 assert_eq!(resolved[0].metadata["custom_key"], "custom_value");
1014 assert!(resolved[0].metadata.contains_key("display"));
1015 }
1016}