Skip to main content

rusty_stack/adapter/
mod.rs

1//! Adapter module — maps component IDs to executors (Rust or legacy shell script).
2//!
3//! This module provides:
4//! - [`AdapterRegistry`] — maps component IDs to executors (Rust or legacy shell script)
5//! - [`RustAdapter`] — routes to native Rust implementations (no shell subprocess)
6//! - [`LegacyAdapter`] — invokes shell scripts with correct argument forwarding
7//! - [`AdapterError`] — structured errors for unknown components and execution failures
8//!
9//! # Design
10//!
11//! The adapter layer is the migration bridge between Rust-native implementations
12//! and legacy shell scripts. For each component ID, the registry determines whether
13//! to use a Rust executor (fast, no subprocess) or fall back to the legacy shell
14//! script (backward compatible).
15//!
16//! ```text
17//! component_id → AdapterRegistry → RustAdapter (native)
18//!                                → LegacyAdapter (shell script)
19//!                                → Err(AdapterNotFound)
20//! ```
21//!
22//! # Validation Assertions
23//!
24//! - **VAL-MIGR-009**: Rust-registered adapter routes to Rust impl, no shell subprocess
25//! - **VAL-MIGR-010**: No Rust adapter → falls back to install_*.sh with correct args
26//! - **VAL-MIGR-011**: Unknown component ID returns Err(AdapterNotFound)
27//! - **VAL-MIGR-012**: Output format matches shell for migrated components
28//! - **VAL-MIGR-013**: Exit codes match shell for success/failure/partial cases
29
30pub mod legacy_adapter;
31pub mod rust_adapter;
32
33use crate::core::types::ExecutorKind;
34use crate::orchestrator::apply::ApplyExecutor;
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37use std::fmt;
38use std::path::{Path, PathBuf};
39
40// ===========================================================================
41// Adapter Error Types
42// ===========================================================================
43
44/// Errors produced by the adapter layer.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub enum AdapterError {
47    /// The requested component ID has no registered adapter.
48    AdapterNotFound { component_id: String },
49    /// The executor failed with a non-zero exit code.
50    ExecutionFailed {
51        component_id: String,
52        exit_code: i32,
53        stderr: String,
54    },
55    /// The executor could not be invoked (e.g., script not found).
56    InvocationError {
57        component_id: String,
58        reason: String,
59    },
60    /// Output format does not match expected shell format.
61    OutputParityError {
62        component_id: String,
63        expected: String,
64        actual: String,
65    },
66}
67
68impl fmt::Display for AdapterError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            AdapterError::AdapterNotFound { component_id } => {
72                write!(f, "no adapter registered for component '{component_id}'")
73            }
74            AdapterError::ExecutionFailed {
75                component_id,
76                exit_code,
77                stderr,
78            } => {
79                write!(
80                    f,
81                    "execution failed for '{component_id}': exit code {exit_code}, stderr: {stderr}"
82                )
83            }
84            AdapterError::InvocationError {
85                component_id,
86                reason,
87            } => {
88                write!(f, "invocation error for '{component_id}': {reason}")
89            }
90            AdapterError::OutputParityError {
91                component_id,
92                expected,
93                actual,
94            } => {
95                write!(
96                    f,
97                    "output parity error for '{component_id}': expected '{expected}', got '{actual}'"
98                )
99            }
100        }
101    }
102}
103
104impl std::error::Error for AdapterError {}
105
106// ===========================================================================
107// Adapter Result
108// ===========================================================================
109
110/// Result of executing a component adapter.
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112pub struct AdapterOutput {
113    /// The component ID that was executed.
114    pub component_id: String,
115    /// The executor kind that was used.
116    pub executor_kind: ExecutorKind,
117    /// Standard output from the executor.
118    pub stdout: String,
119    /// Standard error from the executor.
120    pub stderr: String,
121    /// Exit code (0 = success).
122    pub exit_code: i32,
123    /// Duration of execution in milliseconds.
124    pub duration_ms: u64,
125}
126
127impl AdapterOutput {
128    /// Whether the execution succeeded (exit code 0).
129    pub fn is_success(&self) -> bool {
130        self.exit_code == 0
131    }
132}
133
134// ===========================================================================
135// Adapter Trait
136// ===========================================================================
137
138/// Trait for component adapters.
139///
140/// Each adapter knows how to execute a specific component's installation.
141/// Implementations may use Rust-native logic or delegate to shell scripts.
142pub trait Adapter: Send + Sync {
143    /// Execute the adapter for the given component.
144    ///
145    /// Returns the output on success, or an error on failure.
146    fn execute(&self, component_id: &str, version: &str) -> Result<AdapterOutput, AdapterError>;
147
148    /// The kind of executor this adapter uses.
149    fn executor_kind(&self) -> ExecutorKind;
150}
151
152// ===========================================================================
153// Adapter Registry
154// ===========================================================================
155
156/// Registry that maps component IDs to their executor adapters.
157///
158/// The registry maintains two maps:
159/// - **Rust adapters**: Components with native Rust implementations
160/// - **Legacy adapters**: Components that fall back to shell scripts
161///
162/// When looking up a component, Rust adapters take priority. If no Rust
163/// adapter is registered, the legacy adapter is used. If neither exists,
164/// `AdapterNotFound` is returned.
165pub struct AdapterRegistry {
166    /// Rust-native adapters, keyed by component ID.
167    rust_adapters: HashMap<String, Box<dyn Adapter>>,
168    /// Legacy shell script adapter (handles all legacy components).
169    legacy_adapter: Option<Box<dyn Adapter>>,
170    /// Set of component IDs that are known to have legacy scripts.
171    known_legacy_ids: HashMap<String, String>,
172}
173
174impl AdapterRegistry {
175    /// Create a new empty adapter registry.
176    pub fn new() -> Self {
177        Self {
178            rust_adapters: HashMap::new(),
179            legacy_adapter: None,
180            known_legacy_ids: HashMap::new(),
181        }
182    }
183
184    /// Create a new adapter registry with the default legacy adapter.
185    ///
186    /// The legacy adapter uses the scripts directory to find install_*.sh files.
187    /// All 17 known components are registered for legacy fallback.
188    pub fn with_legacy(scripts_dir: impl Into<PathBuf>) -> Self {
189        let scripts_dir = scripts_dir.into();
190        let mut registry = Self::new();
191        registry.legacy_adapter = Some(Box::new(legacy_adapter::LegacyAdapter::new(
192            scripts_dir.clone(),
193        )));
194        registry.register_known_legacy_ids(&scripts_dir);
195        registry
196    }
197
198    /// Register a Rust adapter for a component.
199    ///
200    /// Rust adapters take priority over legacy adapters.
201    pub fn register_rust(&mut self, component_id: impl Into<String>, adapter: Box<dyn Adapter>) {
202        self.rust_adapters.insert(component_id.into(), adapter);
203    }
204
205    /// Register all 17 known component IDs as legacy-capable.
206    fn register_known_legacy_ids(&mut self, _scripts_dir: &Path) {
207        let known = crate::platform::registry::known_components();
208        for info in known {
209            if !info.installer_script.is_empty() {
210                self.known_legacy_ids
211                    .insert(info.id.clone(), info.installer_script.clone());
212            }
213        }
214    }
215
216    /// Look up the adapter info for a component ID.
217    ///
218    /// Returns an [`AdapterRef`] describing which executor would be used,
219    /// without actually executing anything.
220    ///
221    /// Priority:
222    /// 1. Rust adapter (if registered)
223    /// 2. Legacy adapter (if component has an installer script)
224    /// 3. AdapterNotFound error
225    pub fn get_adapter(&self, component_id: &str) -> Result<AdapterRef, AdapterError> {
226        // Priority 1: Rust adapter
227        if self.rust_adapters.contains_key(component_id) {
228            return Ok(AdapterRef {
229                executor_kind: ExecutorKind::Rust,
230                component_id: component_id.to_string(),
231            });
232        }
233
234        // Priority 2: Legacy adapter
235        if self.legacy_adapter.is_some() {
236            // Check if this component has a known legacy script
237            if self.known_legacy_ids.contains_key(component_id) {
238                return Ok(AdapterRef {
239                    executor_kind: ExecutorKind::LegacyScript,
240                    component_id: component_id.to_string(),
241                });
242            }
243        }
244
245        // Priority 3: Not found
246        Err(AdapterError::AdapterNotFound {
247            component_id: component_id.to_string(),
248        })
249    }
250
251    /// Execute a component via the appropriate adapter.
252    ///
253    /// Routes to Rust adapter if registered, otherwise falls back to legacy.
254    pub fn execute(
255        &self,
256        component_id: &str,
257        version: &str,
258    ) -> Result<AdapterOutput, AdapterError> {
259        // Priority 1: Rust adapter
260        if let Some(rust_adapter) = self.rust_adapters.get(component_id) {
261            return rust_adapter.execute(component_id, version);
262        }
263
264        // Priority 2: Legacy adapter
265        if let Some(legacy) = &self.legacy_adapter {
266            if self.known_legacy_ids.contains_key(component_id) {
267                return legacy.execute(component_id, version);
268            }
269        }
270
271        // Priority 3: Not found
272        Err(AdapterError::AdapterNotFound {
273            component_id: component_id.to_string(),
274        })
275    }
276
277    /// Check if a component has a Rust adapter registered.
278    pub fn has_rust_adapter(&self, component_id: &str) -> bool {
279        self.rust_adapters.contains_key(component_id)
280    }
281
282    /// Check if a component has a legacy adapter available.
283    pub fn has_legacy_adapter(&self, component_id: &str) -> bool {
284        self.known_legacy_ids.contains_key(component_id)
285    }
286
287    /// Get the executor kind for a component.
288    pub fn executor_kind(&self, component_id: &str) -> Result<ExecutorKind, AdapterError> {
289        let adapter_ref = self.get_adapter(component_id)?;
290        Ok(adapter_ref.executor_kind)
291    }
292
293    /// Get the number of registered Rust adapters.
294    pub fn rust_adapter_count(&self) -> usize {
295        self.rust_adapters.len()
296    }
297
298    /// Get the number of known legacy component IDs.
299    pub fn legacy_adapter_count(&self) -> usize {
300        self.known_legacy_ids.len()
301    }
302
303    /// List all component IDs that have Rust adapters registered.
304    pub fn rust_component_ids(&self) -> Vec<String> {
305        self.rust_adapters.keys().cloned().collect()
306    }
307
308    /// List all component IDs that have legacy adapters available.
309    pub fn legacy_component_ids(&self) -> Vec<String> {
310        self.known_legacy_ids.keys().cloned().collect()
311    }
312}
313
314impl Default for AdapterRegistry {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320/// Reference to an adapter returned by the registry.
321///
322/// This enum avoids lifetime issues by returning the executor kind
323/// and component ID rather than a reference to the adapter itself.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct AdapterRef {
326    /// The executor kind for this adapter.
327    pub executor_kind: ExecutorKind,
328    /// The component ID that was looked up.
329    pub component_id: String,
330}
331
332// ---------------------------------------------------------------------------
333// Bridge: AdapterRegistry as ApplyExecutor
334// ---------------------------------------------------------------------------
335
336/// An [`ApplyExecutor`] that delegates to the [`AdapterRegistry`].
337///
338/// This bridges the adapter layer with the apply engine, allowing the
339/// apply engine to use adapters without knowing about them directly.
340pub struct RegistryExecutor {
341    registry: AdapterRegistry,
342}
343
344impl RegistryExecutor {
345    /// Create a new registry executor with the given adapter registry.
346    pub fn new(registry: AdapterRegistry) -> Self {
347        Self { registry }
348    }
349}
350
351impl ApplyExecutor for RegistryExecutor {
352    fn apply_component(&self, component_id: &str, proposed_version: &str) -> Result<(), String> {
353        match self.registry.execute(component_id, proposed_version) {
354            Ok(output) if output.is_success() => Ok(()),
355            Ok(output) => Err(format!(
356                "adapter failed for '{component_id}': exit code {}, stderr: {}",
357                output.exit_code, output.stderr
358            )),
359            Err(e) => Err(format!("adapter error for '{component_id}': {e}")),
360        }
361    }
362}
363
364// ===========================================================================
365// Tests
366// ===========================================================================
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    // -----------------------------------------------------------------------
373    // Test Helpers
374    // -----------------------------------------------------------------------
375
376    /// A mock Rust adapter that always succeeds with a predictable output.
377    struct MockRustAdapter {
378        output_prefix: String,
379    }
380
381    impl MockRustAdapter {
382        fn new(prefix: &str) -> Self {
383            Self {
384                output_prefix: prefix.to_string(),
385            }
386        }
387    }
388
389    impl Adapter for MockRustAdapter {
390        fn execute(
391            &self,
392            component_id: &str,
393            version: &str,
394        ) -> Result<AdapterOutput, AdapterError> {
395            Ok(AdapterOutput {
396                component_id: component_id.to_string(),
397                executor_kind: ExecutorKind::Rust,
398                stdout: format!("{}: {version} installed", self.output_prefix),
399                stderr: String::new(),
400                exit_code: 0,
401                duration_ms: 10,
402            })
403        }
404
405        fn executor_kind(&self) -> ExecutorKind {
406            ExecutorKind::Rust
407        }
408    }
409
410    /// A mock Rust adapter that always fails.
411    struct FailingRustAdapter;
412
413    impl Adapter for FailingRustAdapter {
414        fn execute(
415            &self,
416            component_id: &str,
417            _version: &str,
418        ) -> Result<AdapterOutput, AdapterError> {
419            Ok(AdapterOutput {
420                component_id: component_id.to_string(),
421                executor_kind: ExecutorKind::Rust,
422                stdout: String::new(),
423                stderr: "installation failed".to_string(),
424                exit_code: 1,
425                duration_ms: 100,
426            })
427        }
428
429        fn executor_kind(&self) -> ExecutorKind {
430            ExecutorKind::Rust
431        }
432    }
433
434    /// A mock Rust adapter that simulates partial failure (exit code 2).
435    struct PartialFailureRustAdapter;
436
437    impl Adapter for PartialFailureRustAdapter {
438        fn execute(
439            &self,
440            component_id: &str,
441            _version: &str,
442        ) -> Result<AdapterOutput, AdapterError> {
443            Ok(AdapterOutput {
444                component_id: component_id.to_string(),
445                executor_kind: ExecutorKind::Rust,
446                stdout: "partially completed".to_string(),
447                stderr: "some steps failed".to_string(),
448                exit_code: 2,
449                duration_ms: 50,
450            })
451        }
452
453        fn executor_kind(&self) -> ExecutorKind {
454            ExecutorKind::Rust
455        }
456    }
457
458    /// Create a test registry with no adapters.
459    fn empty_registry() -> AdapterRegistry {
460        AdapterRegistry::new()
461    }
462
463    /// Create a test registry with a Rust adapter for "test-component".
464    fn registry_with_rust_adapter() -> AdapterRegistry {
465        let mut registry = AdapterRegistry::new();
466        registry.register_rust(
467            "test-component",
468            Box::new(MockRustAdapter::new("rust-impl")),
469        );
470        registry
471    }
472
473    /// Create a test registry with both Rust and legacy adapters.
474    fn registry_with_both() -> AdapterRegistry {
475        let mut registry = AdapterRegistry::new();
476        // Register a Rust adapter for "pytorch"
477        registry.register_rust("pytorch", Box::new(MockRustAdapter::new("rust-pytorch")));
478        // Register legacy adapter with known IDs
479        registry.legacy_adapter = Some(Box::new(legacy_adapter::LegacyAdapter::new(
480            PathBuf::from("/nonexistent/scripts"),
481        )));
482        // Register known legacy IDs
483        let known = crate::platform::registry::known_components();
484        for info in known {
485            if !info.installer_script.is_empty() {
486                registry
487                    .known_legacy_ids
488                    .insert(info.id.clone(), info.installer_script.clone());
489            }
490        }
491        registry
492    }
493
494    // -----------------------------------------------------------------------
495    // VAL-MIGR-009: Rust-registered adapter routes to Rust impl, no shell subprocess
496    // -----------------------------------------------------------------------
497
498    #[test]
499    fn test_rust_adapter_dispatches_to_rust_impl() {
500        let registry = registry_with_rust_adapter();
501        let result = registry.execute("test-component", "1.0.0").unwrap();
502
503        assert_eq!(result.executor_kind, ExecutorKind::Rust);
504        assert_eq!(result.exit_code, 0);
505        assert!(result.stdout.contains("rust-impl"));
506        assert!(result.stdout.contains("1.0.0 installed"));
507    }
508
509    #[test]
510    fn test_rust_adapter_no_shell_subprocess() {
511        let registry = registry_with_rust_adapter();
512        let result = registry.execute("test-component", "1.0.0").unwrap();
513
514        // Rust adapter output should not mention shell/bash/script
515        assert!(!result.stdout.contains("bash"));
516        assert!(!result.stdout.contains("sh"));
517        assert_eq!(result.executor_kind, ExecutorKind::Rust);
518    }
519
520    #[test]
521    fn test_rust_adapter_takes_priority_over_legacy() {
522        let registry = registry_with_both();
523
524        // pytorch has a Rust adapter registered, so it should use Rust
525        let result = registry.execute("pytorch", "2.5.0").unwrap();
526        assert_eq!(result.executor_kind, ExecutorKind::Rust);
527        assert!(result.stdout.contains("rust-pytorch"));
528    }
529
530    // -----------------------------------------------------------------------
531    // VAL-MIGR-010: No Rust adapter → falls back to install_*.sh with correct args
532    // -----------------------------------------------------------------------
533
534    #[test]
535    fn test_legacy_fallback_when_no_rust_adapter() {
536        let registry = registry_with_both();
537
538        // triton has no Rust adapter, should fall back to legacy
539        let adapter_ref = registry.get_adapter("triton").unwrap();
540        assert_eq!(adapter_ref.executor_kind, ExecutorKind::LegacyScript);
541    }
542
543    #[test]
544    fn test_legacy_adapter_has_correct_script_mapping() {
545        let registry = registry_with_both();
546
547        // Check that known components map to correct scripts
548        assert_eq!(
549            registry.known_legacy_ids.get("pytorch").map(|s| s.as_str()),
550            Some("install_pytorch_rocm.sh")
551        );
552        assert_eq!(
553            registry.known_legacy_ids.get("triton").map(|s| s.as_str()),
554            Some("install_triton_multi.sh")
555        );
556        assert_eq!(
557            registry
558                .known_legacy_ids
559                .get("flash-attn")
560                .map(|s| s.as_str()),
561            Some("install_flash_attention_ck.sh")
562        );
563        assert_eq!(
564            registry
565                .known_legacy_ids
566                .get("deepspeed")
567                .map(|s| s.as_str()),
568            Some("install_deepspeed.sh")
569        );
570    }
571
572    #[test]
573    fn test_all_17_known_components_have_legacy_mapping() {
574        let registry = registry_with_both();
575
576        // Components that should have legacy scripts (15 out of 17 -
577        // rocm-smi and permanent-env don't have installer scripts)
578        let legacy_count = registry.known_legacy_ids.len();
579        assert!(
580            legacy_count >= 15,
581            "Expected at least 15 legacy mappings, got {legacy_count}"
582        );
583    }
584
585    // -----------------------------------------------------------------------
586    // VAL-MIGR-011: Unknown component ID returns Err(AdapterNotFound)
587    // -----------------------------------------------------------------------
588
589    #[test]
590    fn test_unknown_component_returns_adapter_not_found() {
591        let registry = empty_registry();
592        let result = registry.execute("nonexistent-component", "1.0.0");
593
594        assert!(result.is_err());
595        let err = result.unwrap_err();
596        assert!(matches!(err, AdapterError::AdapterNotFound { .. }));
597    }
598
599    #[test]
600    fn test_unknown_component_error_contains_id() {
601        let registry = empty_registry();
602        let result = registry.execute("fake-component-xyz", "1.0.0");
603
604        let err = result.unwrap_err();
605        let msg = err.to_string();
606        assert!(msg.contains("fake-component-xyz"));
607    }
608
609    #[test]
610    fn test_multiple_unknown_ids_return_not_found() {
611        let registry = empty_registry();
612        let unknown_ids = [
613            "does-not-exist",
614            "also-fake",
615            "not-a-component",
616            "imaginary-tool",
617            "xyz-123",
618        ];
619
620        for id in &unknown_ids {
621            let result = registry.execute(id, "1.0.0");
622            assert!(
623                matches!(result, Err(AdapterError::AdapterNotFound { .. })),
624                "Expected AdapterNotFound for '{id}'"
625            );
626        }
627    }
628
629    #[test]
630    fn test_unknown_with_rust_registry_still_not_found() {
631        let registry = registry_with_rust_adapter();
632        // Only "test-component" is registered; others should fail
633        let result = registry.execute("other-component", "1.0.0");
634        assert!(matches!(result, Err(AdapterError::AdapterNotFound { .. })));
635    }
636
637    // -----------------------------------------------------------------------
638    // VAL-MIGR-012: Output format matches shell for migrated components
639    // -----------------------------------------------------------------------
640
641    #[test]
642    fn test_rust_adapter_output_format_matches_shell_convention() {
643        let registry = registry_with_rust_adapter();
644        let result = registry.execute("test-component", "2.0.0").unwrap();
645
646        // Shell scripts conventionally output: "component: version installed"
647        // or similar status messages to stdout
648        assert!(result.stdout.contains("installed"));
649        assert!(result.stdout.contains("2.0.0"));
650    }
651
652    #[test]
653    fn test_adapter_output_has_all_required_fields() {
654        let registry = registry_with_rust_adapter();
655        let result = registry.execute("test-component", "1.0.0").unwrap();
656
657        // AdapterOutput must have all fields populated
658        assert!(!result.component_id.is_empty());
659        assert_eq!(result.component_id, "test-component");
660        assert!(result.duration_ms > 0);
661        assert_eq!(result.exit_code, 0);
662    }
663
664    // -----------------------------------------------------------------------
665    // VAL-MIGR-013: Exit codes match shell for success/failure/partial cases
666    // -----------------------------------------------------------------------
667
668    #[test]
669    fn test_exit_code_0_on_success() {
670        let registry = registry_with_rust_adapter();
671        let result = registry.execute("test-component", "1.0.0").unwrap();
672        assert_eq!(result.exit_code, 0);
673        assert!(result.is_success());
674    }
675
676    #[test]
677    fn test_exit_code_1_on_failure() {
678        let mut registry = AdapterRegistry::new();
679        registry.register_rust("fail-comp", Box::new(FailingRustAdapter));
680
681        let result = registry.execute("fail-comp", "1.0.0").unwrap();
682        assert_eq!(result.exit_code, 1);
683        assert!(!result.is_success());
684        assert!(!result.stderr.is_empty());
685    }
686
687    #[test]
688    fn test_exit_code_2_on_partial_failure() {
689        let mut registry = AdapterRegistry::new();
690        registry.register_rust("partial-comp", Box::new(PartialFailureRustAdapter));
691
692        let result = registry.execute("partial-comp", "1.0.0").unwrap();
693        assert_eq!(result.exit_code, 2);
694        assert!(!result.is_success());
695        assert!(!result.stdout.is_empty());
696        assert!(!result.stderr.is_empty());
697    }
698
699    // -----------------------------------------------------------------------
700    // AdapterError Display and serde
701    // -----------------------------------------------------------------------
702
703    #[test]
704    fn test_adapter_error_display() {
705        let err = AdapterError::AdapterNotFound {
706            component_id: "test".to_string(),
707        };
708        assert!(err.to_string().contains("test"));
709        assert!(err.to_string().contains("no adapter registered"));
710
711        let err = AdapterError::ExecutionFailed {
712            component_id: "pytorch".to_string(),
713            exit_code: 1,
714            stderr: "error msg".to_string(),
715        };
716        assert!(err.to_string().contains("exit code 1"));
717        assert!(err.to_string().contains("error msg"));
718
719        let err = AdapterError::InvocationError {
720            component_id: "rocm".to_string(),
721            reason: "script not found".to_string(),
722        };
723        assert!(err.to_string().contains("script not found"));
724    }
725
726    #[test]
727    fn test_adapter_error_serde_roundtrip() {
728        let errors = vec![
729            AdapterError::AdapterNotFound {
730                component_id: "test".to_string(),
731            },
732            AdapterError::ExecutionFailed {
733                component_id: "pytorch".to_string(),
734                exit_code: 1,
735                stderr: "failed".to_string(),
736            },
737            AdapterError::InvocationError {
738                component_id: "rocm".to_string(),
739                reason: "not found".to_string(),
740            },
741            AdapterError::OutputParityError {
742                component_id: "triton".to_string(),
743                expected: "ok".to_string(),
744                actual: "err".to_string(),
745            },
746        ];
747
748        for err in &errors {
749            let json = serde_json::to_string(err).unwrap();
750            let back: AdapterError = serde_json::from_str(&json).unwrap();
751            assert_eq!(err, &back);
752        }
753    }
754
755    // -----------------------------------------------------------------------
756    // AdapterOutput serde and helpers
757    // -----------------------------------------------------------------------
758
759    #[test]
760    fn test_adapter_output_serde_roundtrip() {
761        let output = AdapterOutput {
762            component_id: "pytorch".to_string(),
763            executor_kind: ExecutorKind::Rust,
764            stdout: "installed".to_string(),
765            stderr: String::new(),
766            exit_code: 0,
767            duration_ms: 150,
768        };
769        let json = serde_json::to_string(&output).unwrap();
770        let back: AdapterOutput = serde_json::from_str(&json).unwrap();
771        assert_eq!(output, back);
772    }
773
774    #[test]
775    fn test_adapter_output_is_success() {
776        let success = AdapterOutput {
777            component_id: "test".to_string(),
778            executor_kind: ExecutorKind::Rust,
779            stdout: String::new(),
780            stderr: String::new(),
781            exit_code: 0,
782            duration_ms: 10,
783        };
784        assert!(success.is_success());
785
786        let failure = AdapterOutput {
787            component_id: "test".to_string(),
788            executor_kind: ExecutorKind::Rust,
789            stdout: String::new(),
790            stderr: "error".to_string(),
791            exit_code: 1,
792            duration_ms: 10,
793        };
794        assert!(!failure.is_success());
795    }
796
797    // -----------------------------------------------------------------------
798    // Registry helpers
799    // -----------------------------------------------------------------------
800
801    #[test]
802    fn test_has_rust_adapter() {
803        let registry = registry_with_rust_adapter();
804        assert!(registry.has_rust_adapter("test-component"));
805        assert!(!registry.has_rust_adapter("other-component"));
806    }
807
808    #[test]
809    fn test_has_legacy_adapter() {
810        let registry = registry_with_both();
811        assert!(registry.has_legacy_adapter("pytorch"));
812        assert!(registry.has_legacy_adapter("triton"));
813        assert!(!registry.has_legacy_adapter("nonexistent"));
814    }
815
816    #[test]
817    fn test_executor_kind_rust() {
818        let registry = registry_with_rust_adapter();
819        let kind = registry.executor_kind("test-component").unwrap();
820        assert_eq!(kind, ExecutorKind::Rust);
821    }
822
823    #[test]
824    fn test_executor_kind_unknown() {
825        let registry = empty_registry();
826        let result = registry.executor_kind("nonexistent");
827        assert!(matches!(result, Err(AdapterError::AdapterNotFound { .. })));
828    }
829
830    #[test]
831    fn test_rust_component_ids() {
832        let registry = registry_with_rust_adapter();
833        let ids = registry.rust_component_ids();
834        assert_eq!(ids, vec!["test-component"]);
835    }
836
837    #[test]
838    fn test_legacy_component_ids() {
839        let registry = registry_with_both();
840        let ids = registry.legacy_component_ids();
841        assert!(!ids.is_empty());
842        assert!(ids.contains(&"pytorch".to_string()));
843        assert!(ids.contains(&"triton".to_string()));
844    }
845
846    // -----------------------------------------------------------------------
847    // RegistryExecutor bridge
848    // -----------------------------------------------------------------------
849
850    #[test]
851    fn test_registry_executor_success() {
852        let mut inner = AdapterRegistry::new();
853        inner.register_rust("test", Box::new(MockRustAdapter::new("ok")));
854
855        let executor = RegistryExecutor::new(inner);
856        let result = executor.apply_component("test", "1.0.0");
857        assert!(result.is_ok());
858    }
859
860    #[test]
861    fn test_registry_executor_failure() {
862        let mut inner = AdapterRegistry::new();
863        inner.register_rust("fail", Box::new(FailingRustAdapter));
864
865        let executor = RegistryExecutor::new(inner);
866        let result = executor.apply_component("fail", "1.0.0");
867        assert!(result.is_err());
868        assert!(result.unwrap_err().contains("exit code 1"));
869    }
870
871    #[test]
872    fn test_registry_executor_not_found() {
873        let inner = AdapterRegistry::new();
874        let executor = RegistryExecutor::new(inner);
875        let result = executor.apply_component("nonexistent", "1.0.0");
876        assert!(result.is_err());
877        assert!(result.unwrap_err().contains("no adapter registered"));
878    }
879
880    // -----------------------------------------------------------------------
881    // Default impl
882    // -----------------------------------------------------------------------
883
884    #[test]
885    fn test_adapter_registry_default() {
886        let registry = AdapterRegistry::default();
887        assert!(registry.rust_adapters.is_empty());
888        assert!(registry.legacy_adapter.is_none());
889        assert!(registry.known_legacy_ids.is_empty());
890    }
891}