Skip to main content

apcore_toolkit/display/
resolver.rs

1// DisplayResolver — sparse binding.yaml display overlay (§5.13).
2//
3// Resolves surface-facing presentation fields (alias, description, guidance)
4// for each ScannedModule by merging:
5//   surface-specific override > display default > binding-level > scanner value
6//
7// The resolved fields are stored in ScannedModule.metadata["display"] and
8// travel through RegistryWriter into FunctionModule.metadata["display"],
9// where CLI/MCP/A2A surfaces read them at render time.
10
11use 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/// Resolves display overlay fields for a list of ScannedModules.
23///
24/// # Usage
25///
26/// ```ignore
27/// let resolver = DisplayResolver::new();
28/// let resolved = resolver.resolve(modules, None, None);
29/// ```
30///
31/// The returned list contains the same ScannedModules with
32/// `metadata["display"]` populated for all surfaces.
33#[derive(Debug, Default)]
34pub struct DisplayResolver;
35
36impl DisplayResolver {
37    /// Create a new DisplayResolver.
38    pub fn new() -> Self {
39        Self
40    }
41
42    /// Apply display overlay to a list of ScannedModules.
43    ///
44    /// # Arguments
45    ///
46    /// * `modules` - ScannedModule instances from a framework scanner.
47    /// * `binding_path` - Path to a single `.binding.yaml` file or a directory
48    ///   of binding files. Optional.
49    /// * `binding_data` - Pre-parsed binding YAML content as a JSON Value
50    ///   (`{"bindings": [...]}`) or a `module_id -> entry` map.
51    ///   Takes precedence over `binding_path`.
52    ///
53    /// # Errors
54    ///
55    /// Returns `Err` if an MCP alias exceeds the 64-character limit or does not
56    /// match the required pattern.
57    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    // ------------------------------------------------------------------
91    // Internal helpers
92    // ------------------------------------------------------------------
93
94    /// Build a module_id -> binding-entry map from the provided sources.
95    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    /// Parse pre-loaded binding data.
110    ///
111    /// Accepts either `{"bindings": [...]}` or a direct `module_id -> entry` map.
112    fn parse_binding_data(data: &Value) -> HashMap<String, Value> {
113        let mut result = HashMap::new();
114
115        // Accept {"bindings": [...]} format
116        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        // Already a map: module_id -> entry
126        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    /// Load binding files from a path (file or directory).
138    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    /// Resolve display fields for a single ScannedModule.
185    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        // -- Resolve cross-surface defaults --
203        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        // -- Resolve per-surface fields --
225        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        // Auto-sanitize MCP alias
248        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        // -- Validate aliases --
286        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    /// Resolve fields for a single surface (cli, mcp, or a2a).
293    ///
294    /// Returns `(surface_dict, alias_was_explicit)`.
295    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    /// Validate surface alias constraints per §5.13.6.
327    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        // MCP: enforce 64-char hard limit (alias was already auto-sanitized)
337        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        // CLI: only validate user-explicitly-set aliases
355        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/// Errors returned by [`DisplayResolver`] operations.
373#[derive(Debug, thiserror::Error)]
374pub enum DisplayResolverError {
375    /// An alias validation constraint was violated.
376    #[error("{0}")]
377    Validation(String),
378}
379
380// -- Utility helpers --
381
382/// Extract a non-empty string field from a JSON value.
383fn 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
389/// Extract a tags array from a JSON value.
390fn 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    /// Helper to create a minimal ScannedModule for testing.
404    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        // Just verify it can be created — it has no configuration.
419        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        // Default alias falls back to module_id
431        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        // description comes from display first, then binding-level, then scanner
484        assert_eq!(display["description"], "Binding-level desc");
485    }
486
487    #[test]
488    fn test_resolution_chain_precedence() {
489        // surface-specific > display default > binding-level > scanner value
490        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        // Top-level uses display default
511        assert_eq!(display["description"], "Display desc");
512        // CLI surface uses its own override
513        assert_eq!(display["cli"]["description"], "CLI desc");
514        // MCP falls through to display default
515        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        // Dots get replaced with underscores
527        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        // Test with a binding that would produce an invalid MCP alias.
586        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        // CLI alias should fall back to default alias because "Get-User" is invalid
622        assert_eq!(display["cli"]["alias"], "get-user");
623    }
624
625    #[test]
626    fn test_cli_alias_non_explicit_not_validated() {
627        // When CLI alias comes from scanner (not explicitly set), no validation
628        let resolver = DisplayResolver::new();
629        let modules = vec![make_module("MyModule", "Description")];
630
631        // No binding data, so CLI alias inherits from module_id which has uppercase
632        let resolved = resolver.resolve(modules, None, None).unwrap();
633        let display = resolved[0].metadata.get("display").unwrap();
634
635        // Should NOT fall back — accepts non-conforming alias from scanner
636        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        // suggested_alias should be used instead of module_id
651        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        // display.tags takes precedence over entry.tags
673        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        // Should still succeed, just no bindings applied
817        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        // Non-matching file should be ignored
884        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        // All three surfaces should be present
920        assert!(display.get("cli").is_some());
921        assert!(display.get("mcp").is_some());
922        assert!(display.get("a2a").is_some());
923
924        // Each surface should have alias and description
925        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        // binding_data should win over binding_path
977        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}