1use std::collections::HashMap;
12use std::path::Path;
13
14use std::sync::LazyLock;
15
16use regex::Regex;
17use serde_json::{json, Value};
18use tracing::{debug, info, warn};
19
20static MCP_ALIAS_SANITIZE_RE: LazyLock<Regex> =
21 LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9_-]").expect("valid regex"));
22static MCP_ALIAS_PATTERN_RE: LazyLock<Regex> =
23 LazyLock::new(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_-]*$").expect("valid regex"));
24static CLI_ALIAS_PATTERN_RE: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9_-]*$").expect("valid regex"));
26
27use crate::types::ScannedModule;
28
29const MCP_ALIAS_MAX: usize = 64;
30
31#[derive(Debug, Default)]
43pub struct DisplayResolver;
44
45impl DisplayResolver {
46 pub fn new() -> Self {
48 Self
49 }
50
51 pub fn resolve(
67 &self,
68 modules: Vec<ScannedModule>,
69 binding_path: Option<&Path>,
70 binding_data: Option<&Value>,
71 ) -> Result<Vec<ScannedModule>, DisplayResolverError> {
72 let binding_map = self.build_binding_map(binding_path, binding_data);
73
74 if !binding_map.is_empty() {
75 let matched = modules
76 .iter()
77 .filter(|m| binding_map.contains_key(&m.module_id))
78 .count();
79 info!(
80 "DisplayResolver: {}/{} modules matched binding entries.",
81 matched,
82 modules.len(),
83 );
84 if matched == 0 {
85 warn!(
86 "DisplayResolver: binding map loaded {} entries but none matched \
87 any scanned module_id — check binding.yaml module_id values.",
88 binding_map.len(),
89 );
90 }
91 }
92
93 modules
94 .into_iter()
95 .map(|m| self.resolve_one(m, &binding_map))
96 .collect()
97 }
98
99 fn build_binding_map(
105 &self,
106 binding_path: Option<&Path>,
107 binding_data: Option<&Value>,
108 ) -> HashMap<String, Value> {
109 if let Some(data) = binding_data {
110 return Self::parse_binding_data(data);
111 }
112 if let Some(path) = binding_path {
113 return self.load_binding_files(path);
114 }
115 HashMap::new()
116 }
117
118 fn parse_binding_data(data: &Value) -> HashMap<String, Value> {
122 let mut result = HashMap::new();
123
124 if let Some(bindings) = data.get("bindings").and_then(|v| v.as_array()) {
126 for entry in bindings {
127 if let Some(module_id) = entry.get("module_id").and_then(|v| v.as_str()) {
128 result.insert(module_id.to_string(), entry.clone());
129 }
130 }
131 return result;
132 }
133
134 if let Some(obj) = data.as_object() {
136 for (k, v) in obj {
137 if v.is_object() {
138 result.insert(k.clone(), v.clone());
139 }
140 }
141 }
142
143 result
144 }
145
146 fn load_binding_files(&self, path: &Path) -> HashMap<String, Value> {
157 let mut result = HashMap::new();
158
159 let files: Vec<std::path::PathBuf> = if path.is_file() {
160 vec![path.to_path_buf()]
161 } else if path.is_dir() {
162 let mut entries: Vec<std::path::PathBuf> = Vec::new();
163 match std::fs::read_dir(path) {
164 Ok(read_dir) => {
165 for entry_result in read_dir {
166 match entry_result {
167 Ok(entry) => {
168 let p = entry.path();
169 let is_binding = p
170 .file_name()
171 .and_then(|n| n.to_str())
172 .is_some_and(|n| n.ends_with(".binding.yaml"));
173 if is_binding {
174 entries.push(p);
175 }
176 }
177 Err(e) => {
178 warn!(
179 "DisplayResolver: skipping unreadable entry in {:?}: {}",
180 path, e
181 );
182 }
183 }
184 }
185 }
186 Err(e) => {
187 warn!(
188 "DisplayResolver: failed to read binding directory {:?}: {}",
189 path, e
190 );
191 return result;
192 }
193 }
194 entries.sort();
195 entries
196 } else {
197 warn!("DisplayResolver: binding path not found: {:?}", path);
198 return result;
199 };
200
201 for f in files {
202 match std::fs::read_to_string(&f) {
203 Ok(content) => match serde_yaml_ng::from_str::<Value>(&content) {
204 Ok(data) => {
205 let parsed = Self::parse_binding_data(&data);
206 result.extend(parsed);
207 }
208 Err(e) => {
209 warn!("DisplayResolver: failed to parse {:?}: {}", f, e);
210 }
211 },
212 Err(e) => {
213 warn!("DisplayResolver: failed to load {:?}: {}", f, e);
214 }
215 }
216 }
217
218 result
219 }
220
221 fn resolve_one(
223 &self,
224 mut module: ScannedModule,
225 binding_map: &HashMap<String, Value>,
226 ) -> Result<ScannedModule, DisplayResolverError> {
227 let empty_obj = json!({});
228 let entry = binding_map.get(&module.module_id).unwrap_or(&empty_obj);
229 let display_cfg = entry.get("display").unwrap_or(&empty_obj);
230
231 let defaults = compute_display_defaults(&module, entry, display_cfg);
232
233 let (cli_surface, cli_alias_explicit) = self.resolve_surface(
234 display_cfg,
235 "cli",
236 &defaults.alias,
237 &defaults.description,
238 &defaults.guidance,
239 );
240 let (mut mcp_surface, _) = self.resolve_surface(
241 display_cfg,
242 "mcp",
243 &defaults.alias,
244 &defaults.description,
245 &defaults.guidance,
246 );
247 let (a2a_surface, _) = self.resolve_surface(
248 display_cfg,
249 "a2a",
250 &defaults.alias,
251 &defaults.description,
252 &defaults.guidance,
253 );
254
255 let raw_mcp_alias = mcp_surface
256 .get("alias")
257 .and_then(|v| v.as_str())
258 .unwrap_or("")
259 .to_string();
260 let sanitized = sanitize_mcp_alias(&raw_mcp_alias);
261 if sanitized != raw_mcp_alias {
262 debug!(
263 "Module '{}': MCP alias auto-sanitized '{}' → '{}'.",
264 module.module_id, raw_mcp_alias, sanitized
265 );
266 }
267 mcp_surface["alias"] = json!(sanitized);
268
269 let mut display = assemble_display(&defaults, cli_surface, mcp_surface, a2a_surface);
270 self.validate_aliases(&mut display, &module.module_id, cli_alias_explicit)?;
271
272 module.metadata.insert("display".into(), display);
273 Ok(module)
274 }
275
276 fn resolve_surface(
280 &self,
281 display_cfg: &Value,
282 key: &str,
283 default_alias: &str,
284 default_description: &str,
285 default_guidance: &Option<String>,
286 ) -> (Value, bool) {
287 let empty = json!({});
288 let sc = display_cfg.get(key).unwrap_or(&empty);
289 let alias_explicit = sc.get("alias").and_then(|v| v.as_str()).is_some();
290
291 let alias = str_or(sc, "alias").unwrap_or(default_alias);
292 let description = str_or(sc, "description").unwrap_or(default_description);
293 let guidance = str_or(sc, "guidance")
294 .map(|s| s.to_string())
295 .or_else(|| default_guidance.clone());
296
297 let mut surface = json!({
298 "alias": alias,
299 "description": description,
300 });
301 if let Some(g) = &guidance {
302 surface["guidance"] = json!(g);
303 } else {
304 surface["guidance"] = Value::Null;
305 }
306
307 (surface, alias_explicit)
308 }
309
310 fn validate_aliases(
312 &self,
313 display: &mut Value,
314 module_id: &str,
315 cli_alias_explicit: bool,
316 ) -> Result<(), DisplayResolverError> {
317 let mcp_alias_pattern = &*MCP_ALIAS_PATTERN_RE;
318 let cli_alias_pattern = &*CLI_ALIAS_PATTERN_RE;
319
320 let mcp_alias = display["mcp"]["alias"].as_str().unwrap_or("").to_string();
322
323 if mcp_alias.len() > MCP_ALIAS_MAX {
324 return Err(DisplayResolverError::Validation(format!(
325 "Module '{}': MCP alias '{}' exceeds {}-character hard limit (OpenAI spec). \
326 Set display.mcp.alias to a shorter value.",
327 module_id, mcp_alias, MCP_ALIAS_MAX,
328 )));
329 }
330 if !mcp_alias_pattern.is_match(&mcp_alias) {
331 return Err(DisplayResolverError::Validation(format!(
332 "Module '{}': MCP alias '{}' does not match \
333 required pattern ^[a-zA-Z_][a-zA-Z0-9_-]*$.",
334 module_id, mcp_alias,
335 )));
336 }
337
338 if cli_alias_explicit {
340 let cli_alias = display["cli"]["alias"].as_str().unwrap_or("").to_string();
341 if !cli_alias_pattern.is_match(&cli_alias) {
342 let default_alias = display["alias"].as_str().unwrap_or("").to_string();
343 warn!(
344 "Module '{}': CLI alias '{}' does not match shell-safe pattern \
345 ^[a-z][a-z0-9_-]*$ — falling back to default alias '{}'.",
346 module_id, cli_alias, default_alias,
347 );
348 display["cli"]["alias"] = json!(default_alias);
349 }
350 }
351
352 Ok(())
353 }
354}
355
356#[derive(Debug, thiserror::Error)]
358pub enum DisplayResolverError {
359 #[error("{0}")]
361 Validation(String),
362}
363
364struct DisplayDefaults {
368 alias: String,
369 description: String,
370 documentation: Option<String>,
371 guidance: Option<String>,
372 tags: Vec<String>,
373}
374
375fn compute_display_defaults(
377 module: &ScannedModule,
378 entry: &Value,
379 display_cfg: &Value,
380) -> DisplayDefaults {
381 let binding_desc = entry.get("description").and_then(|v| v.as_str());
382 let binding_docs = entry.get("documentation").and_then(|v| v.as_str());
383
384 let field_alias = module
386 .suggested_alias
387 .as_deref()
388 .filter(|s| !s.is_empty())
389 .map(|s| s.to_string());
390 let metadata_alias = module
391 .metadata
392 .get("suggested_alias")
393 .and_then(|v| v.as_str())
394 .map(|s| s.to_string());
395 let suggested_alias = field_alias.or(metadata_alias);
396
397 let alias = str_or(display_cfg, "alias")
398 .or(suggested_alias.as_deref())
399 .unwrap_or(&module.module_id)
400 .to_string();
401 let description = str_or(display_cfg, "description")
402 .or(binding_desc)
403 .unwrap_or(&module.description)
404 .to_string();
405 let documentation = str_or(display_cfg, "documentation")
406 .or(binding_docs)
407 .or(module.documentation.as_deref())
408 .map(|s| s.to_string());
409 let guidance = str_or(display_cfg, "guidance").map(|s| s.to_string());
410 let tags = tags_or(display_cfg, "tags")
411 .or_else(|| tags_or(entry, "tags"))
412 .unwrap_or_else(|| module.tags.clone());
413
414 DisplayDefaults {
415 alias,
416 description,
417 documentation,
418 guidance,
419 tags,
420 }
421}
422
423fn sanitize_mcp_alias(raw: &str) -> String {
426 let mut s = MCP_ALIAS_SANITIZE_RE.replace_all(raw, "_").to_string();
427 if s.starts_with(|c: char| c.is_ascii_digit()) {
428 s = format!("_{s}");
429 }
430 s
431}
432
433fn assemble_display(defaults: &DisplayDefaults, cli: Value, mcp: Value, a2a: Value) -> Value {
435 let mut display = json!({
436 "alias": defaults.alias,
437 "description": defaults.description,
438 "guidance": defaults.guidance,
439 "tags": defaults.tags,
440 "cli": cli,
441 "mcp": mcp,
442 "a2a": a2a,
443 });
444 display["documentation"] = match &defaults.documentation {
445 Some(doc) => json!(doc),
446 None => Value::Null,
447 };
448 display
449}
450
451fn str_or<'a>(val: &'a Value, key: &str) -> Option<&'a str> {
455 val.get(key)
456 .and_then(|v| v.as_str())
457 .filter(|s| !s.is_empty())
458}
459
460fn tags_or(val: &Value, key: &str) -> Option<Vec<String>> {
462 val.get(key).and_then(|v| v.as_array()).map(|arr| {
463 arr.iter()
464 .filter_map(|v| v.as_str().map(|s| s.to_string()))
465 .collect()
466 })
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use serde_json::json;
473
474 fn make_module(module_id: &str, description: &str) -> ScannedModule {
476 ScannedModule::new(
477 module_id.into(),
478 description.into(),
479 json!({"type": "object"}),
480 json!({"type": "object"}),
481 vec!["default-tag".into()],
482 format!("app:{module_id}"),
483 )
484 }
485
486 #[test]
487 fn test_new_creates_default_instance() {
488 let resolver = DisplayResolver::new();
489 let _ = format!("{:?}", resolver);
491 }
492
493 #[test]
494 fn test_resolve_passthrough_no_bindings() {
495 let resolver = DisplayResolver::new();
496 let modules = vec![make_module("users.get", "Get a user")];
497 let resolved = resolver.resolve(modules, None, None).unwrap();
498
499 assert_eq!(resolved.len(), 1);
500 let display = resolved[0].metadata.get("display").unwrap();
501 assert_eq!(display["alias"], "users.get");
503 assert_eq!(display["description"], "Get a user");
504 }
505
506 #[test]
507 fn test_resolve_with_binding_data_map_format() {
508 let resolver = DisplayResolver::new();
509 let modules = vec![make_module("users.get", "Get a user")];
510
511 let binding_data = json!({
512 "users.get": {
513 "display": {
514 "alias": "get-user",
515 "description": "Retrieve a user by ID",
516 "guidance": "Use when you know the user ID"
517 }
518 }
519 });
520
521 let resolved = resolver
522 .resolve(modules, None, Some(&binding_data))
523 .unwrap();
524 let display = resolved[0].metadata.get("display").unwrap();
525
526 assert_eq!(display["alias"], "get-user");
527 assert_eq!(display["description"], "Retrieve a user by ID");
528 assert_eq!(display["guidance"], "Use when you know the user ID");
529 }
530
531 #[test]
532 fn test_resolve_with_binding_data_bindings_list_format() {
533 let resolver = DisplayResolver::new();
534 let modules = vec![make_module("users.get", "Get a user")];
535
536 let binding_data = json!({
537 "bindings": [
538 {
539 "module_id": "users.get",
540 "description": "Binding-level desc",
541 "display": {
542 "alias": "get-user"
543 }
544 }
545 ]
546 });
547
548 let resolved = resolver
549 .resolve(modules, None, Some(&binding_data))
550 .unwrap();
551 let display = resolved[0].metadata.get("display").unwrap();
552
553 assert_eq!(display["alias"], "get-user");
554 assert_eq!(display["description"], "Binding-level desc");
556 }
557
558 #[test]
559 fn test_resolution_chain_precedence() {
560 let resolver = DisplayResolver::new();
562 let modules = vec![make_module("users.get", "Scanner desc")];
563
564 let binding_data = json!({
565 "users.get": {
566 "description": "Binding desc",
567 "display": {
568 "description": "Display desc",
569 "cli": {
570 "description": "CLI desc"
571 }
572 }
573 }
574 });
575
576 let resolved = resolver
577 .resolve(modules, None, Some(&binding_data))
578 .unwrap();
579 let display = resolved[0].metadata.get("display").unwrap();
580
581 assert_eq!(display["description"], "Display desc");
583 assert_eq!(display["cli"]["description"], "CLI desc");
585 assert_eq!(display["mcp"]["description"], "Display desc");
587 }
588
589 #[test]
590 fn test_mcp_alias_auto_sanitization_dots() {
591 let resolver = DisplayResolver::new();
592 let modules = vec![make_module("image.resize", "Resize image")];
593
594 let resolved = resolver.resolve(modules, None, None).unwrap();
595 let display = resolved[0].metadata.get("display").unwrap();
596
597 assert_eq!(display["mcp"]["alias"], "image_resize");
599 }
600
601 #[test]
602 fn test_mcp_alias_auto_sanitization_spaces() {
603 let resolver = DisplayResolver::new();
604 let modules = vec![make_module("users.get user", "Get user")];
605
606 let resolved = resolver.resolve(modules, None, None).unwrap();
607 let display = resolved[0].metadata.get("display").unwrap();
608
609 assert_eq!(display["mcp"]["alias"], "users_get_user");
610 }
611
612 #[test]
613 fn test_mcp_alias_leading_digit_prefix() {
614 let resolver = DisplayResolver::new();
615 let binding_data = json!({
616 "test": {
617 "display": {
618 "alias": "1get-user"
619 }
620 }
621 });
622 let modules = vec![make_module("test", "Test")];
623
624 let resolved = resolver
625 .resolve(modules, None, Some(&binding_data))
626 .unwrap();
627 let display = resolved[0].metadata.get("display").unwrap();
628
629 assert_eq!(display["mcp"]["alias"], "_1get-user");
630 }
631
632 #[test]
633 fn test_mcp_alias_exceeds_max_length() {
634 let resolver = DisplayResolver::new();
635 let long_alias = "a".repeat(65);
636 let binding_data = json!({
637 "test": {
638 "display": {
639 "alias": long_alias
640 }
641 }
642 });
643 let modules = vec![make_module("test", "Test")];
644
645 let result = resolver.resolve(modules, None, Some(&binding_data));
646 assert!(result.is_err());
647 let err = result.unwrap_err();
648 assert!(err.to_string().contains("exceeds 64-character hard limit"));
649 }
650
651 #[test]
652 fn test_mcp_alias_invalid_pattern() {
653 let resolver = DisplayResolver::new();
654 let modules = vec![make_module("test", "Test")];
655
656 let binding_data2 = json!({
658 "test": {
659 "display": {
660 "mcp": {
661 "alias": "---invalid"
662 }
663 }
664 }
665 });
666 let result = resolver.resolve(modules, None, Some(&binding_data2));
667 assert!(result.is_err());
668 let err = result.unwrap_err();
669 assert!(err.to_string().contains("does not match"));
670 }
671
672 #[test]
673 fn test_cli_alias_explicit_invalid_falls_back() {
674 let resolver = DisplayResolver::new();
675 let binding_data = json!({
676 "users.get": {
677 "display": {
678 "alias": "get-user",
679 "cli": {
680 "alias": "Get-User"
681 }
682 }
683 }
684 });
685 let modules = vec![make_module("users.get", "Get user")];
686
687 let resolved = resolver
688 .resolve(modules, None, Some(&binding_data))
689 .unwrap();
690 let display = resolved[0].metadata.get("display").unwrap();
691
692 assert_eq!(display["cli"]["alias"], "get-user");
694 }
695
696 #[test]
697 fn test_cli_alias_non_explicit_not_validated() {
698 let resolver = DisplayResolver::new();
700 let modules = vec![make_module("MyModule", "Description")];
701
702 let resolved = resolver.resolve(modules, None, None).unwrap();
704 let display = resolved[0].metadata.get("display").unwrap();
705
706 assert_eq!(display["cli"]["alias"], "MyModule");
708 }
709
710 #[test]
711 fn test_suggested_alias_fallback() {
712 let resolver = DisplayResolver::new();
713 let mut module = make_module("users__get_user", "Get user");
714 module
715 .metadata
716 .insert("suggested_alias".into(), json!("get_user"));
717
718 let resolved = resolver.resolve(vec![module], None, None).unwrap();
719 let display = resolved[0].metadata.get("display").unwrap();
720
721 assert_eq!(display["alias"], "get_user");
723 }
724
725 #[test]
728 fn test_suggested_alias_field_only() {
729 let resolver = DisplayResolver::new();
730 let mut module = make_module("tasks.user_data.post", "Create");
731 module.suggested_alias = Some("tasks.user_data.create".into());
732
733 let resolved = resolver.resolve(vec![module], None, None).unwrap();
734 let display = resolved[0].metadata.get("display").unwrap();
735 assert_eq!(display["alias"], "tasks.user_data.create");
736 }
737
738 #[test]
739 fn test_suggested_alias_metadata_only() {
740 let resolver = DisplayResolver::new();
741 let mut module = make_module("tasks.user_data.post", "Create");
742 module
743 .metadata
744 .insert("suggested_alias".into(), json!("tasks.user_data.legacy"));
745
746 let resolved = resolver.resolve(vec![module], None, None).unwrap();
747 let display = resolved[0].metadata.get("display").unwrap();
748 assert_eq!(display["alias"], "tasks.user_data.legacy");
749 }
750
751 #[test]
752 fn test_suggested_alias_field_precedence_over_metadata() {
753 let resolver = DisplayResolver::new();
754 let mut module = make_module("tasks.user_data.post", "Create");
755 module.suggested_alias = Some("tasks.user_data.create".into());
756 module
757 .metadata
758 .insert("suggested_alias".into(), json!("tasks.user_data.legacy"));
759
760 let resolved = resolver.resolve(vec![module], None, None).unwrap();
761 let display = resolved[0].metadata.get("display").unwrap();
762 assert_eq!(display["alias"], "tasks.user_data.create");
763 }
764
765 #[test]
766 fn test_suggested_alias_empty_field_falls_through_to_metadata() {
767 let resolver = DisplayResolver::new();
768 let mut module = make_module("tasks.user_data.post", "Create");
769 module.suggested_alias = Some("".into());
770 module
771 .metadata
772 .insert("suggested_alias".into(), json!("tasks.user_data.legacy"));
773
774 let resolved = resolver.resolve(vec![module], None, None).unwrap();
775 let display = resolved[0].metadata.get("display").unwrap();
776 assert_eq!(display["alias"], "tasks.user_data.legacy");
777 }
778
779 #[test]
780 fn test_suggested_alias_none_field_falls_through_to_metadata() {
781 let resolver = DisplayResolver::new();
782 let mut module = make_module("tasks.user_data.post", "Create");
783 module.suggested_alias = None;
784 module
785 .metadata
786 .insert("suggested_alias".into(), json!("tasks.user_data.legacy"));
787
788 let resolved = resolver.resolve(vec![module], None, None).unwrap();
789 let display = resolved[0].metadata.get("display").unwrap();
790 assert_eq!(display["alias"], "tasks.user_data.legacy");
791 }
792
793 #[test]
794 fn test_suggested_alias_neither_falls_through_to_module_id() {
795 let resolver = DisplayResolver::new();
796 let module = make_module("tasks.user_data.post", "Create");
797 let resolved = resolver.resolve(vec![module], None, None).unwrap();
800 let display = resolved[0].metadata.get("display").unwrap();
801 assert_eq!(display["alias"], "tasks.user_data.post");
802 }
803
804 #[test]
805 fn test_tags_resolution_from_display() {
806 let resolver = DisplayResolver::new();
807 let binding_data = json!({
808 "test": {
809 "tags": ["binding-tag"],
810 "display": {
811 "tags": ["display-tag"]
812 }
813 }
814 });
815 let modules = vec![make_module("test", "Test")];
816
817 let resolved = resolver
818 .resolve(modules, None, Some(&binding_data))
819 .unwrap();
820 let display = resolved[0].metadata.get("display").unwrap();
821
822 let tags: Vec<String> = display["tags"]
824 .as_array()
825 .unwrap()
826 .iter()
827 .map(|v| v.as_str().unwrap().to_string())
828 .collect();
829 assert_eq!(tags, vec!["display-tag"]);
830 }
831
832 #[test]
833 fn test_tags_resolution_from_binding_entry() {
834 let resolver = DisplayResolver::new();
835 let binding_data = json!({
836 "test": {
837 "tags": ["binding-tag"]
838 }
839 });
840 let modules = vec![make_module("test", "Test")];
841
842 let resolved = resolver
843 .resolve(modules, None, Some(&binding_data))
844 .unwrap();
845 let display = resolved[0].metadata.get("display").unwrap();
846
847 let tags: Vec<String> = display["tags"]
848 .as_array()
849 .unwrap()
850 .iter()
851 .map(|v| v.as_str().unwrap().to_string())
852 .collect();
853 assert_eq!(tags, vec!["binding-tag"]);
854 }
855
856 #[test]
857 fn test_tags_fallback_to_scanner() {
858 let resolver = DisplayResolver::new();
859 let modules = vec![make_module("test", "Test")];
860
861 let resolved = resolver.resolve(modules, None, None).unwrap();
862 let display = resolved[0].metadata.get("display").unwrap();
863
864 let tags: Vec<String> = display["tags"]
865 .as_array()
866 .unwrap()
867 .iter()
868 .map(|v| v.as_str().unwrap().to_string())
869 .collect();
870 assert_eq!(tags, vec!["default-tag"]);
871 }
872
873 #[test]
874 fn test_documentation_resolution() {
875 let resolver = DisplayResolver::new();
876 let mut module = make_module("test", "Test");
877 module.documentation = Some("Scanner docs".into());
878
879 let binding_data = json!({
880 "test": {
881 "documentation": "Binding docs",
882 "display": {
883 "documentation": "Display docs"
884 }
885 }
886 });
887
888 let resolved = resolver
889 .resolve(vec![module], None, Some(&binding_data))
890 .unwrap();
891 let display = resolved[0].metadata.get("display").unwrap();
892
893 assert_eq!(display["documentation"], "Display docs");
894 }
895
896 #[test]
897 fn test_documentation_fallback_to_binding() {
898 let resolver = DisplayResolver::new();
899 let mut module = make_module("test", "Test");
900 module.documentation = Some("Scanner docs".into());
901
902 let binding_data = json!({
903 "test": {
904 "documentation": "Binding docs"
905 }
906 });
907
908 let resolved = resolver
909 .resolve(vec![module], None, Some(&binding_data))
910 .unwrap();
911 let display = resolved[0].metadata.get("display").unwrap();
912
913 assert_eq!(display["documentation"], "Binding docs");
914 }
915
916 #[test]
917 fn test_documentation_fallback_to_scanner() {
918 let resolver = DisplayResolver::new();
919 let mut module = make_module("test", "Test");
920 module.documentation = Some("Scanner docs".into());
921
922 let resolved = resolver.resolve(vec![module], None, None).unwrap();
923 let display = resolved[0].metadata.get("display").unwrap();
924
925 assert_eq!(display["documentation"], "Scanner docs");
926 }
927
928 #[test]
929 fn test_multiple_modules() {
930 let resolver = DisplayResolver::new();
931 let modules = vec![
932 make_module("mod_a", "Module A"),
933 make_module("mod_b", "Module B"),
934 make_module("mod_c", "Module C"),
935 ];
936
937 let binding_data = json!({
938 "mod_a": {
939 "display": { "alias": "alias-a" }
940 },
941 "mod_c": {
942 "display": { "alias": "alias-c" }
943 }
944 });
945
946 let resolved = resolver
947 .resolve(modules, None, Some(&binding_data))
948 .unwrap();
949 assert_eq!(resolved.len(), 3);
950 assert_eq!(resolved[0].metadata["display"]["alias"], "alias-a");
951 assert_eq!(resolved[1].metadata["display"]["alias"], "mod_b");
952 assert_eq!(resolved[2].metadata["display"]["alias"], "alias-c");
953 }
954
955 #[test]
956 fn test_binding_map_zero_matches_still_resolves() {
957 let resolver = DisplayResolver::new();
958 let modules = vec![make_module("actual_id", "Description")];
959
960 let binding_data = json!({
961 "nonexistent_id": {
962 "display": { "alias": "nope" }
963 }
964 });
965
966 let resolved = resolver
968 .resolve(modules, None, Some(&binding_data))
969 .unwrap();
970 assert_eq!(resolved.len(), 1);
971 assert_eq!(resolved[0].metadata["display"]["alias"], "actual_id");
972 }
973
974 #[test]
975 fn test_parse_binding_data_bindings_list() {
976 let data = json!({
977 "bindings": [
978 { "module_id": "a", "description": "Module A" },
979 { "module_id": "b", "description": "Module B" },
980 { "description": "No ID — should be skipped" }
981 ]
982 });
983 let map = DisplayResolver::parse_binding_data(&data);
984 assert_eq!(map.len(), 2);
985 assert!(map.contains_key("a"));
986 assert!(map.contains_key("b"));
987 }
988
989 #[test]
990 fn test_parse_binding_data_map() {
991 let data = json!({
992 "a": { "display": { "alias": "alias-a" } },
993 "b": { "display": { "alias": "alias-b" } },
994 "scalar": "not-an-object"
995 });
996 let map = DisplayResolver::parse_binding_data(&data);
997 assert_eq!(map.len(), 2);
998 assert!(map.contains_key("a"));
999 assert!(map.contains_key("b"));
1000 }
1001
1002 #[test]
1003 fn test_load_binding_files_single_file() {
1004 let resolver = DisplayResolver::new();
1005 let dir = tempfile::tempdir().unwrap();
1006 let file_path = dir.path().join("test.binding.yaml");
1007 std::fs::write(
1008 &file_path,
1009 "bindings:\n - module_id: test\n description: From file\n",
1010 )
1011 .unwrap();
1012
1013 let map = resolver.load_binding_files(&file_path);
1014 assert_eq!(map.len(), 1);
1015 assert!(map.contains_key("test"));
1016 }
1017
1018 #[test]
1019 fn test_load_binding_files_directory() {
1020 let resolver = DisplayResolver::new();
1021 let dir = tempfile::tempdir().unwrap();
1022
1023 std::fs::write(
1024 dir.path().join("a.binding.yaml"),
1025 "bindings:\n - module_id: a\n description: A\n",
1026 )
1027 .unwrap();
1028 std::fs::write(
1029 dir.path().join("b.binding.yaml"),
1030 "bindings:\n - module_id: b\n description: B\n",
1031 )
1032 .unwrap();
1033 std::fs::write(dir.path().join("c.yaml"), "bindings:\n - module_id: c\n").unwrap();
1035
1036 let map = resolver.load_binding_files(dir.path());
1037 assert_eq!(map.len(), 2);
1038 assert!(map.contains_key("a"));
1039 assert!(map.contains_key("b"));
1040 assert!(!map.contains_key("c"));
1041 }
1042
1043 #[test]
1044 fn test_load_binding_files_nonexistent_path() {
1045 let resolver = DisplayResolver::new();
1046 let map = resolver.load_binding_files(Path::new("/nonexistent/path"));
1047 assert!(map.is_empty());
1048 }
1049
1050 #[test]
1051 fn test_load_binding_files_invalid_yaml() {
1052 let resolver = DisplayResolver::new();
1053 let dir = tempfile::tempdir().unwrap();
1054 let file_path = dir.path().join("bad.binding.yaml");
1055 std::fs::write(&file_path, "{{{{not valid yaml").unwrap();
1056
1057 let map = resolver.load_binding_files(&file_path);
1058 assert!(map.is_empty());
1059 }
1060
1061 #[test]
1062 fn test_surface_fields_populated() {
1063 let resolver = DisplayResolver::new();
1064 let modules = vec![make_module("test_mod", "Test desc")];
1065
1066 let resolved = resolver.resolve(modules, None, None).unwrap();
1067 let display = resolved[0].metadata.get("display").unwrap();
1068
1069 assert!(display.get("cli").is_some());
1071 assert!(display.get("mcp").is_some());
1072 assert!(display.get("a2a").is_some());
1073
1074 for surface in &["cli", "mcp", "a2a"] {
1076 assert!(display[surface].get("alias").is_some());
1077 assert!(display[surface].get("description").is_some());
1078 }
1079 }
1080
1081 #[test]
1082 fn test_mcp_alias_valid_stays_unchanged() {
1083 let resolver = DisplayResolver::new();
1084 let binding_data = json!({
1085 "test": {
1086 "display": {
1087 "mcp": {
1088 "alias": "valid_alias-123"
1089 }
1090 }
1091 }
1092 });
1093 let modules = vec![make_module("test", "Test")];
1094
1095 let resolved = resolver
1096 .resolve(modules, None, Some(&binding_data))
1097 .unwrap();
1098 let display = resolved[0].metadata.get("display").unwrap();
1099
1100 assert_eq!(display["mcp"]["alias"], "valid_alias-123");
1101 }
1102
1103 #[test]
1104 fn test_binding_data_takes_precedence_over_path() {
1105 let resolver = DisplayResolver::new();
1106 let dir = tempfile::tempdir().unwrap();
1107 let file_path = dir.path().join("test.binding.yaml");
1108 std::fs::write(
1109 &file_path,
1110 "bindings:\n - module_id: test\n display:\n alias: from-file\n",
1111 )
1112 .unwrap();
1113
1114 let binding_data = json!({
1115 "test": {
1116 "display": { "alias": "from-data" }
1117 }
1118 });
1119
1120 let modules = vec![make_module("test", "Test")];
1121 let resolved = resolver
1122 .resolve(modules, Some(file_path.as_path()), Some(&binding_data))
1123 .unwrap();
1124 let display = resolved[0].metadata.get("display").unwrap();
1125
1126 assert_eq!(display["alias"], "from-data");
1128 }
1129
1130 #[test]
1131 fn test_mcp_alias_64_chars_exactly_ok() {
1132 let resolver = DisplayResolver::new();
1133 let alias_64 = "a".repeat(64);
1134 let binding_data = json!({
1135 "test": {
1136 "display": {
1137 "alias": alias_64
1138 }
1139 }
1140 });
1141 let modules = vec![make_module("test", "Test")];
1142
1143 let result = resolver.resolve(modules, None, Some(&binding_data));
1144 assert!(result.is_ok());
1145 }
1146
1147 #[test]
1148 fn test_empty_modules_list() {
1149 let resolver = DisplayResolver::new();
1150 let resolved = resolver.resolve(vec![], None, None).unwrap();
1151 assert!(resolved.is_empty());
1152 }
1153
1154 #[test]
1155 fn test_original_metadata_preserved() {
1156 let resolver = DisplayResolver::new();
1157 let mut module = make_module("test", "Test");
1158 module
1159 .metadata
1160 .insert("custom_key".into(), json!("custom_value"));
1161
1162 let resolved = resolver.resolve(vec![module], None, None).unwrap();
1163 assert_eq!(resolved[0].metadata["custom_key"], "custom_value");
1164 assert!(resolved[0].metadata.contains_key("display"));
1165 }
1166
1167 #[test]
1175 fn test_load_binding_files_ignores_non_binding_entries() {
1176 let dir = tempfile::tempdir().unwrap();
1177 std::fs::write(
1178 dir.path().join("ok.binding.yaml"),
1179 "bindings:\n - module_id: ok_mod\n display:\n alias: ok\n",
1180 )
1181 .unwrap();
1182 std::fs::create_dir(dir.path().join("sub")).unwrap();
1184 std::fs::write(dir.path().join("notes.txt"), "not a binding file").unwrap();
1185
1186 let resolver = DisplayResolver::new();
1187 let resolved = resolver
1188 .resolve(vec![make_module("ok_mod", "ok")], Some(dir.path()), None)
1189 .unwrap();
1190
1191 assert_eq!(resolved.len(), 1);
1192 let display = resolved[0].metadata.get("display").unwrap();
1193 assert_eq!(display["alias"], "ok");
1194 }
1195}