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 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/// Resolves display overlay fields for a list of ScannedModules.
32///
33/// # Usage
34///
35/// ```ignore
36/// let resolver = DisplayResolver::new();
37/// let resolved = resolver.resolve(modules, None, None);
38/// ```
39///
40/// The returned list contains the same ScannedModules with
41/// `metadata["display"]` populated for all surfaces.
42#[derive(Debug, Default)]
43pub struct DisplayResolver;
44
45impl DisplayResolver {
46    /// Create a new DisplayResolver.
47    pub fn new() -> Self {
48        Self
49    }
50
51    /// Apply display overlay to a list of ScannedModules.
52    ///
53    /// # Arguments
54    ///
55    /// * `modules` - ScannedModule instances from a framework scanner.
56    /// * `binding_path` - Path to a single `.binding.yaml` file or a directory
57    ///   of binding files. Optional.
58    /// * `binding_data` - Pre-parsed binding YAML content as a JSON Value
59    ///   (`{"bindings": [...]}`) or a `module_id -> entry` map.
60    ///   Takes precedence over `binding_path`.
61    ///
62    /// # Errors
63    ///
64    /// Returns `Err` if an MCP alias exceeds the 64-character limit or does not
65    /// match the required pattern.
66    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    // ------------------------------------------------------------------
100    // Internal helpers
101    // ------------------------------------------------------------------
102
103    /// Build a module_id -> binding-entry map from the provided sources.
104    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    /// Parse pre-loaded binding data.
119    ///
120    /// Accepts either `{"bindings": [...]}` or a direct `module_id -> entry` map.
121    fn parse_binding_data(data: &Value) -> HashMap<String, Value> {
122        let mut result = HashMap::new();
123
124        // Accept {"bindings": [...]} format
125        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        // Already a map: module_id -> entry
135        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    /// Load binding files from a path (file or directory).
147    ///
148    /// Per-entry I/O failures (permission denied, unreadable symlinks) are
149    /// surfaced via `tracing::warn!` rather than silently dropped. This
150    /// mirrors the error-handling contract of
151    /// [`crate::binding_loader::BindingLoader::load`] — the two loaders
152    /// traverse the same directory shape and should behave consistently
153    /// when the filesystem misbehaves. The return type stays
154    /// `HashMap<String, Value>` (rather than `Result<…, …>`) to preserve
155    /// backward compatibility for 0.5.x.
156    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    /// Resolve display fields for a single ScannedModule.
222    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    /// Resolve fields for a single surface (cli, mcp, or a2a).
277    ///
278    /// Returns `(surface_dict, alias_was_explicit)`.
279    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    /// Validate surface alias constraints per §5.13.6.
311    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        // MCP: enforce 64-char hard limit (alias was already auto-sanitized)
321        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        // CLI: only validate user-explicitly-set aliases
339        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/// Errors returned by [`DisplayResolver`] operations.
357#[derive(Debug, thiserror::Error)]
358pub enum DisplayResolverError {
359    /// An alias validation constraint was violated.
360    #[error("{0}")]
361    Validation(String),
362}
363
364// -- Module-level helpers extracted from resolve_one --
365
366/// Resolved cross-surface display defaults before per-surface overrides.
367struct DisplayDefaults {
368    alias: String,
369    description: String,
370    documentation: Option<String>,
371    guidance: Option<String>,
372    tags: Vec<String>,
373}
374
375/// Compute cross-surface display defaults from binding and scanner data.
376fn 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    // Top-level suggested_alias takes precedence over metadata["suggested_alias"].
385    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
423/// Sanitize a raw MCP alias: replace disallowed characters with `_` and
424/// prefix a leading digit with `_` (OpenAI function-name rules).
425fn 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
433/// Assemble the final display JSON from resolved defaults and surface values.
434fn 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
451// -- Utility helpers --
452
453/// Extract a non-empty string field from a JSON value.
454fn 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
460/// Extract a tags array from a JSON value.
461fn 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    /// Helper to create a minimal ScannedModule for testing.
475    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        // Just verify it can be created — it has no configuration.
490        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        // Default alias falls back to module_id
502        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        // description comes from display first, then binding-level, then scanner
555        assert_eq!(display["description"], "Binding-level desc");
556    }
557
558    #[test]
559    fn test_resolution_chain_precedence() {
560        // surface-specific > display default > binding-level > scanner value
561        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        // Top-level uses display default
582        assert_eq!(display["description"], "Display desc");
583        // CLI surface uses its own override
584        assert_eq!(display["cli"]["description"], "CLI desc");
585        // MCP falls through to display default
586        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        // Dots get replaced with underscores
598        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        // Test with a binding that would produce an invalid MCP alias.
657        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        // CLI alias should fall back to default alias because "Get-User" is invalid
693        assert_eq!(display["cli"]["alias"], "get-user");
694    }
695
696    #[test]
697    fn test_cli_alias_non_explicit_not_validated() {
698        // When CLI alias comes from scanner (not explicitly set), no validation
699        let resolver = DisplayResolver::new();
700        let modules = vec![make_module("MyModule", "Description")];
701
702        // No binding data, so CLI alias inherits from module_id which has uppercase
703        let resolved = resolver.resolve(modules, None, None).unwrap();
704        let display = resolved[0].metadata.get("display").unwrap();
705
706        // Should NOT fall back — accepts non-conforming alias from scanner
707        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        // suggested_alias should be used instead of module_id
722        assert_eq!(display["alias"], "get_user");
723    }
724
725    // ---- Dual-source suggested_alias resolution ----
726
727    #[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        // Neither field nor metadata alias set.
798
799        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        // display.tags takes precedence over entry.tags
823        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        // Should still succeed, just no bindings applied
967        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        // Non-matching file should be ignored
1034        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        // All three surfaces should be present
1070        assert!(display.get("cli").is_some());
1071        assert!(display.get("mcp").is_some());
1072        assert!(display.get("a2a").is_some());
1073
1074        // Each surface should have alias and description
1075        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        // binding_data should win over binding_path
1127        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    /// Behavioural guard for D2-1: `load_binding_files` must keep loading
1168    /// the readable entries when the directory contains non-binding content
1169    /// (e.g. a subdirectory or an unrelated file). Previously the whole
1170    /// traversal used `filter_map(Result::ok)` and silently dropped errors;
1171    /// the new iteration walks per-entry with structured `warn!` on I/O
1172    /// failures. This test exercises the happy-path continuation — a true
1173    /// per-entry I/O error is hard to provoke deterministically in CI.
1174    #[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        // Sibling subdirectory and unrelated file that must not abort the load.
1183        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}