Skip to main content

runtimo_core/
capability.rs

1//! Capability trait, typed capability trait, and registry.
2//!
3//! The [`Capability`] trait is the core abstraction — every pluggable operation
4//! (file read, file write, shell exec, etc.) implements this trait. The
5//! [`CapabilityRegistry`] collects and dispatches to registered capabilities.
6//!
7//! The [`TypedCapability`] trait provides compile-time type safety for capability
8//! arguments. A blanket impl bridges [`TypedCapability`] to [`Capability`], so
9//! the executor's `&dyn Capability` dynamic dispatch continues to work while
10//! direct callers get typed args.
11
12use crate::telemetry::Telemetry;
13use crate::Result;
14use serde::de::DeserializeOwned;
15use serde_json::Value;
16use std::path::PathBuf;
17
18/// Error type for capability execution failures.
19///
20/// # Variants
21/// - `InvalidArgs`: Deserialization or validation failed. Message describes
22///   the specific field error from serde or manual validation.
23/// - `PermissionDenied`: Path or operation rejected by security policy
24///   (e.g., path traversal outside allowed prefix).
25/// - `NotFound`: Target file or resource does not exist at the specified path.
26/// - `Io`: Underlying I/O operation failed (read, write, create, delete).
27/// - `Git`: Git command returned an error or produced invalid output.
28/// - `Internal`: Unexpected internal failure that should not occur in normal
29///   operation. Indicates a bug in the capability implementation.
30///
31/// # Invariants
32/// - Every error carries a human-readable message suitable for logging.
33/// - Callers can match on variant to decide retry/abort/skip behavior.
34/// - `Io` wraps `std::io::Error` via `From` for ergonomic `?` propagation.
35///
36/// # Errors
37///
38/// This enum IS the error type — no separate error channel exists.
39/// Capabilities return `Result<Output, CapabilityError>`.
40#[derive(Debug, thiserror::Error)]
41#[allow(clippy::exhaustive_enums)] // error enums are intentionally exhaustive
42pub enum CapabilityError {
43    /// Deserialization or field validation failed.
44    ///
45    /// Contains a description of the specific validation failure
46    /// (e.g., "missing field `file_path`", "invalid type: string, expected PathBuf").
47    #[error("invalid arguments: {0}")]
48    InvalidArgs(String),
49
50    /// Path or operation rejected by security policy.
51    ///
52    /// Triggered when a path traversal check fails or an operation
53    /// targets a location outside allowed prefixes.
54    #[error("blocked: {0}")]
55    PermissionDenied(String),
56
57    /// Target file or resource does not exist.
58    ///
59    /// The message identifies the missing resource (typically its path).
60    #[error("file not found: {0}")]
61    NotFound(String),
62
63    /// Underlying I/O operation failed.
64    ///
65    /// Wraps `std::io::Error` for ergonomic `?` propagation from
66    /// filesystem operations (read, write, create, rename, delete).
67    #[error("io error: {0}")]
68    Io(#[from] std::io::Error),
69
70    /// Git command returned an error or produced invalid output.
71    ///
72    /// Contains the git error message or stderr output.
73    #[error("git error: {0}")]
74    Git(String),
75
76    /// Unexpected internal failure.
77    ///
78    /// Should not occur in normal operation. Indicates a bug in the
79    /// capability implementation or an invariant violation.
80    #[error("internal error: {0}")]
81    Internal(String),
82}
83
84/// Context provided to capabilities during execution.
85///
86/// Carries execution metadata such as dry-run mode, the owning job ID,
87/// and the working directory for relative path resolution.
88///
89/// Use [`Context::new`] to create instances — this ensures consistent
90/// field initialization across CLI, executor, and daemon code paths.
91#[allow(clippy::exhaustive_structs)] // fields are write-through API contract
92pub struct Context {
93    /// If true, the capability should not perform side effects.
94    pub dry_run: bool,
95    /// The job ID that owns this execution.
96    pub job_id: String,
97    /// Working directory for relative path resolution.
98    pub working_dir: PathBuf,
99}
100
101impl Context {
102    /// Creates a new execution context.
103    ///
104    /// Uses `std::env::current_dir()` as the default working directory.
105    /// The caller should override `working_dir` if an explicit directory
106    /// is known (e.g., from daemon dispatch parameters).
107    #[must_use]
108    pub fn new(dry_run: bool, job_id: String) -> Self {
109        Self {
110            dry_run,
111            job_id,
112            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
113        }
114    }
115
116    /// Creates a new execution context with an explicit working directory.
117    #[must_use]
118    pub fn with_working_dir(dry_run: bool, job_id: String, working_dir: PathBuf) -> Self {
119        Self {
120            dry_run,
121            job_id,
122            working_dir,
123        }
124    }
125}
126
127/// Output from capability execution.
128///
129/// Returned by every [`Capability::execute`] call. Provides a standardized
130/// envelope across all capabilities — `status` distinguishes ok/error,
131/// `output` carries human-readable text, `data` holds structured JSON,
132/// and `error` contains the failure message when status is `"error"`.
133///
134/// # Invariants
135///
136/// - `status` is always `"ok"` or `"error"`.
137/// - When `status == "error"`, `error` is `Some`.
138/// - When `status == "ok"`, `error` is `None`.
139/// - `data` is capability-specific structured output (may be `None`).
140/// - `backup_path` is set when a file was backed up before mutation.
141/// - `artifacts` lists paths of files created or modified by the capability.
142///
143/// # Constructors
144///
145/// Use [`Output::ok`] for successful executions and [`Output::error`] for
146/// failures. Do not construct `Output` directly — the constructors enforce
147/// the invariants above.
148#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
149#[allow(clippy::exhaustive_structs)] // fields are write-through API contract
150pub struct Output {
151    /// Execution status: `"ok"` or `"error"`.
152    pub status: String,
153    /// Human-readable result message.
154    pub output: String,
155    /// Capability-specific structured data (JSON). `None` when the
156    /// capability produces no structured output.
157    pub data: Option<Value>,
158    /// Path to a backup file created before mutation. `None` when no
159    /// backup was created (e.g., read-only operations, new files).
160    pub backup_path: Option<PathBuf>,
161    /// Error message when `status == "error"`. `None` when `status == "ok"`.
162    pub error: Option<String>,
163    /// Wall-clock execution duration in milliseconds.
164    pub duration_ms: u64,
165    /// Telemetry delta captured around the execution (before/after).
166    pub telemetry_delta: Telemetry,
167    /// Paths of files created or modified by this capability execution.
168    pub artifacts: Vec<PathBuf>,
169}
170
171impl Output {
172    /// Creates a successful output with the given human-readable message.
173    ///
174    /// Sets `status` to `"ok"`, `error` to `None`, and all other fields
175    /// to sensible defaults. Callers should populate `data`, `backup_path`,
176    /// and `artifacts` after construction as needed.
177    ///
178    /// # Examples
179    ///
180    /// ```rust
181    /// use runtimo_core::capability::Output;
182    ///
183    /// let out = Output::ok("Read 42 bytes".into());
184    /// assert_eq!(out.status, "ok");
185    /// assert!(out.error.is_none());
186    /// ```
187    #[must_use]
188    pub fn ok(output: String) -> Self {
189        Self {
190            status: "ok".to_string(),
191            output,
192            data: None,
193            backup_path: None,
194            error: None,
195            duration_ms: 0,
196            telemetry_delta: Telemetry::capture_lightweight(),
197            artifacts: Vec::new(),
198        }
199    }
200
201    /// Creates an error output with the given human-readable message and
202    /// error detail.
203    ///
204    /// Sets `status` to `"error"` and `error` to `Some(error)`. The
205    /// `output` field carries a caller-facing summary; `error` carries
206    /// the machine-parseable failure reason.
207    ///
208    /// # Examples
209    ///
210    /// ```rust
211    /// use runtimo_core::capability::Output;
212    ///
213    /// let out = Output::error("failed".into(), "file not found".into());
214    /// assert_eq!(out.status, "error");
215    /// assert_eq!(out.error.as_deref(), Some("file not found"));
216    /// ```
217    #[must_use]
218    pub fn error(output: String, error: String) -> Self {
219        Self {
220            status: "error".to_string(),
221            output,
222            data: None,
223            backup_path: None,
224            error: Some(error),
225            duration_ms: 0,
226            telemetry_delta: Telemetry::capture_lightweight(),
227            artifacts: Vec::new(),
228        }
229    }
230
231    /// Serializes the output to a JSON [`Value`].
232    ///
233    /// All fields are included — this is the canonical JSON representation
234    /// written to the WAL and returned to clients.
235    #[must_use]
236    pub fn to_json(&self) -> Value {
237        serde_json::to_value(self).unwrap_or(Value::Null)
238    }
239}
240
241/// The Capability trait — all capabilities must implement this.
242///
243/// Each capability defines its name, argument schema, validation logic,
244/// and execution behavior. The executor calls these methods in order:
245/// `name()` → `schema()` → `validate()` → `execute()`.
246///
247/// # Example
248///
249/// ```rust
250/// use runtimo_core::capability::{Capability, Context, Output};
251/// use runtimo_core::Result;
252/// use serde_json::Value;
253///
254/// struct Echo;
255///
256/// impl Capability for Echo {
257///     fn name(&self) -> &'static str { "Echo" }
258///     fn description(&self) -> &'static str { "Echo back arguments" }
259///     fn schema(&self) -> Value { serde_json::json!({"type":"object"}) }
260///     fn validate(&self, _args: &Value) -> Result<()> { Ok(()) }
261///     fn execute(&self, args: &Value, _ctx: &Context) -> Result<Output> {
262///         Ok(Output::ok(format!("echo: {}", args)))
263///     }
264/// }
265/// ```
266pub trait Capability: Send + Sync {
267    /// Returns the capability name (e.g., `"FileRead"`, `"FileWrite"`).
268    ///
269    /// This name is used for registry lookups and WAL event tagging.
270    fn name(&self) -> &'static str;
271
272    /// Returns a one-line human-readable description of what this capability does.
273    ///
274    /// Used by the CLI `list` command and `--help` output to help users
275    /// discover available capabilities.
276    fn description(&self) -> &'static str;
277
278    /// Returns the JSON Schema for the capability's arguments.
279    ///
280    /// The schema is used by [`Capability::validate`] and by the CLI
281    /// to generate `--help` output for each capability.
282    fn schema(&self) -> Value;
283
284    /// Validates the arguments against the schema.
285    ///
286    /// Implementations should deserialize `args` into their typed args struct
287    /// and perform semantic checks (e.g., path traversal rejection).
288    ///
289    /// # Errors
290    ///
291    /// Returns [`Error::SchemaValidationFailed`](crate::Error::SchemaValidationFailed)
292    /// if arguments are malformed or semantically invalid.
293    fn validate(&self, args: &Value) -> Result<()>;
294
295    /// Executes the capability with the given arguments and context.
296    ///
297    /// This is called after `validate()` passes. Implementations should
298    /// perform the actual work and return an [`Output`].
299    ///
300    /// # Errors
301    ///
302    /// Returns [`Error::ExecutionFailed`](crate::Error::ExecutionFailed)
303    /// if the operation cannot be completed.
304    fn execute(&self, args: &Value, ctx: &Context) -> Result<Output>;
305}
306
307/// Typed capability trait — compile-time safe arguments for capabilities.
308///
309/// [`TypedCapability`] provides type-safe argument handling via an associated
310/// `Args` type. Each capability defines its own args struct (e.g.,
311/// [`FileReadArgs`](crate::capabilities::FileReadArgs)) and
312/// implements this trait.
313///
314/// # Blanket impl bridge
315///
316/// A blanket `impl<T: TypedCapability> Capability for T` bridges this trait
317/// to the untyped [`Capability`] trait. This means:
318///
319/// - The executor's `&dyn Capability` dynamic dispatch **still works** —
320///   it calls the blanket impl which deserializes `Value` into `Self::Args`.
321/// - Direct callers can use `TypedCapability` methods with compile-time
322///   type safety — no `Value` deserialization needed.
323///
324/// Both paths coexist. The blanket impl is the bridge.
325///
326/// # Examples
327///
328/// ```rust,ignore
329/// use runtimo_core::capability::{TypedCapability, Context, Output, CapabilityError};
330/// use runtimo_core::capabilities::file_read::FileReadArgs;
331/// use runtimo_core::capabilities::FileRead;
332///
333/// let cap = FileRead;
334/// let args = FileReadArgs { path: "/etc/hostname".into(), max_bytes: None };
335/// // Direct typed call — no Value involved:
336/// // let output = cap.execute(args, &ctx)?;
337/// ```
338pub trait TypedCapability: Send + Sync {
339    /// The typed arguments struct for this capability.
340    ///
341    /// Must implement `DeserializeOwned` so the blanket impl can convert
342    /// from `serde_json::Value`.
343    type Args: DeserializeOwned + Send + Sync;
344
345    /// Returns the capability name (e.g., `"FileRead"`).
346    fn name(&self) -> &'static str;
347
348    /// Returns a one-line description of what this capability does.
349    fn description(&self) -> &'static str;
350
351    /// Returns the JSON Schema for the capability's arguments.
352    fn schema(&self) -> Value;
353
354    /// Executes the capability with typed arguments.
355    ///
356    /// # Errors
357    ///
358    /// Returns [`CapabilityError`] if deserialization, validation, or
359    /// execution fails.
360    fn execute(
361        &self,
362        args: Self::Args,
363        ctx: &Context,
364    ) -> std::result::Result<Output, CapabilityError>;
365
366    /// Dry-run execution — same as [`execute`](TypedCapability::execute)
367    /// but the capability should skip side effects.
368    ///
369    /// Default implementation calls `execute` — capabilities that support
370    /// dry-run should override this.
371    ///
372    /// # Errors
373    ///
374    /// Returns [`CapabilityError`] if the dry-run simulation fails.
375    fn dry_run(
376        &self,
377        args: Self::Args,
378        ctx: &Context,
379    ) -> std::result::Result<Output, CapabilityError> {
380        self.execute(args, ctx)
381    }
382}
383
384/// Blanket implementation bridging [`TypedCapability`] to [`Capability`].
385///
386/// This is the critical bridge that allows the executor's `&dyn Capability`
387/// dynamic dispatch to work with typed capabilities. When the executor calls
388/// `Capability::execute(&dyn Capability, &Value, &Context)`, this impl:
389///
390/// 1. Deserializes the `Value` into `T::Args` (one deserialization).
391/// 2. Calls `TypedCapability::execute` with the typed args.
392/// 3. Maps `CapabilityError` to `crate::Error`.
393///
394/// The `validate` method always returns `Ok(())` because deserialization
395/// **is** validation — `serde_json::from_value` rejects malformed input.
396///
397/// # Why this exists
398///
399/// Without this blanket impl, `TypedCapability` would be unusable by the
400/// executor (which stores `Box<dyn Capability>`). With it, both paths
401/// coexist:
402///
403/// - **Dynamic dispatch** (executor): `cap.execute(&value, &ctx)` — goes
404///   through this blanket impl.
405/// - **Static dispatch** (direct callers): `cap.execute(typed_args, &ctx)` —
406///   calls `TypedCapability::execute` directly.
407impl<T: TypedCapability> Capability for T {
408    fn name(&self) -> &'static str {
409        TypedCapability::name(self)
410    }
411
412    fn description(&self) -> &'static str {
413        TypedCapability::description(self)
414    }
415
416    fn schema(&self) -> Value {
417        TypedCapability::schema(self)
418    }
419
420    fn validate(&self, _args: &Value) -> Result<()> {
421        // Deserialization IS validation. The blanket impl's execute method
422        // deserializes Value into Self::Args, which rejects malformed input.
423        // No separate validate step needed.
424        Ok(())
425    }
426
427    fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
428        let typed_args: T::Args = serde_json::from_value(args.clone())
429            .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
430        TypedCapability::execute(self, typed_args, ctx).map_err(|e| {
431            // Map CapabilityError variants to structured error with JSON-RPC codes.
432            // This preserves the error variant information that would otherwise be
433            // lost to stringification, enabling programmatic error handling.
434            let (code, variant) = match &e {
435                CapabilityError::PermissionDenied(_) => (-32001, "PermissionDenied"),
436                CapabilityError::NotFound(_) => (-32002, "NotFound"),
437                CapabilityError::InvalidArgs(_) => (-32004, "InvalidArgs"),
438                CapabilityError::Io(_) => (-32005, "Io"),
439                CapabilityError::Git(_) => (-32006, "Git"),
440                CapabilityError::Internal(_) => (-32003, "Internal"),
441            };
442            crate::Error::CapabilityExecutionFailed {
443                msg: e.to_string(),
444                variant,
445                code,
446            }
447        })
448    }
449}
450
451/// Registry of available capabilities.
452///
453/// Stores capabilities by name and provides lookup, listing, and registration.
454///
455/// # Example
456///
457/// ```rust,ignore
458/// use runtimo_core::{CapabilityRegistry, FileRead, FileWrite};
459/// use std::path::PathBuf;
460///
461/// let mut registry = CapabilityRegistry::new();
462/// registry.register(FileRead);
463/// registry.register(FileWrite::new(PathBuf::from("/tmp/backups")).unwrap());
464///
465/// assert!(registry.get("FileRead").is_some());
466/// let caps = registry.list();
467/// assert_eq!(caps.len(), 2);
468/// assert!(caps.contains(&"FileRead"));
469/// assert!(caps.contains(&"FileWrite"));
470/// ```
471pub struct CapabilityRegistry {
472    capabilities: std::collections::HashMap<String, Box<dyn Capability>>,
473}
474
475impl CapabilityRegistry {
476    /// Creates an empty registry.
477    #[must_use]
478    pub fn new() -> Self {
479        Self {
480            capabilities: std::collections::HashMap::new(),
481        }
482    }
483
484    /// Registers a capability in the registry.
485    ///
486    /// The capability is stored under its [`Capability::name`]. If a capability
487    /// with the same name already exists, it is replaced.
488    pub fn register<C: Capability + 'static>(&mut self, capability: C) {
489        let name = capability.name().to_string();
490        self.capabilities.insert(name, Box::new(capability));
491    }
492
493    /// Looks up a capability by name (case-insensitive).
494    ///
495    /// Returns `None` if no capability with the given name is registered.
496    #[must_use]
497    pub fn get(&self, name: &str) -> Option<&dyn Capability> {
498        if let Some(cap) = self.capabilities.get(name) {
499            return Some(cap.as_ref());
500        }
501        let name_lower = name.to_lowercase();
502        for (key, cap) in &self.capabilities {
503            if key.to_lowercase() == name_lower {
504                return Some(cap.as_ref());
505            }
506        }
507        None
508    }
509
510    /// Returns the names of all registered capabilities.
511    #[must_use]
512    pub fn list(&self) -> Vec<&str> {
513        self.capabilities.keys().map(|s| s.as_str()).collect()
514    }
515}
516
517impl Default for CapabilityRegistry {
518    fn default() -> Self {
519        Self::new()
520    }
521}
522
523#[cfg(test)]
524#[allow(clippy::unwrap_used)]
525mod tests {
526    use super::*;
527    use serde_json::Value;
528
529    /// A minimal test capability that echoes its name.
530    struct TestCap {
531        name: &'static str,
532    }
533
534    impl Capability for TestCap {
535        fn name(&self) -> &'static str {
536            self.name
537        }
538        fn description(&self) -> &'static str {
539            "test capability"
540        }
541        fn schema(&self) -> Value {
542            serde_json::json!({})
543        }
544        fn validate(&self, _args: &Value) -> crate::Result<()> {
545            Ok(())
546        }
547        fn execute(&self, _args: &Value, _ctx: &Context) -> crate::Result<Output> {
548            Ok(Output::ok("test completed".into()))
549        }
550    }
551
552    #[test]
553    fn test_registry_register_and_get() {
554        let mut reg = CapabilityRegistry::new();
555        reg.register(TestCap { name: "Alpha" });
556
557        let cap = reg.get("Alpha");
558        assert!(cap.is_some(), "Should find registered capability");
559        assert_eq!(cap.unwrap().name(), "Alpha");
560    }
561
562    #[test]
563    fn test_registry_duplicate_name_replaces() {
564        let mut reg = CapabilityRegistry::new();
565        reg.register(TestCap { name: "Beta" });
566        reg.register(TestCap { name: "Beta" }); // second registration replaces
567
568        let cap = reg.get("Beta");
569        assert!(
570            cap.is_some(),
571            "Should still find capability after duplicate registration"
572        );
573        assert_eq!(cap.unwrap().name(), "Beta");
574    }
575
576    #[test]
577    fn test_registry_case_insensitive_lookup() {
578        let mut reg = CapabilityRegistry::new();
579        reg.register(TestCap { name: "ShellExec" });
580
581        // Exact match
582        assert!(reg.get("ShellExec").is_some());
583        // Case-insensitive match
584        assert!(
585            reg.get("shellexec").is_some(),
586            "Case-insensitive lookup should find ShellExec"
587        );
588        assert!(
589            reg.get("SHELLEXEC").is_some(),
590            "Uppercase lookup should find ShellExec"
591        );
592        assert!(
593            reg.get("ShellExec").is_some(),
594            "Exact-case lookup should find ShellExec"
595        );
596    }
597
598    #[test]
599    fn test_registry_unregistered_lookup_returns_none() {
600        let mut reg = CapabilityRegistry::new();
601        reg.register(TestCap { name: "Delta" });
602
603        assert!(reg.get("NoSuchCap").is_none());
604        assert!(reg.get("").is_none());
605        // Unregistered even with case-insensitive match
606        assert!(reg.get("gamma").is_none());
607    }
608
609    #[test]
610    fn test_registry_list() {
611        let mut reg = CapabilityRegistry::new();
612        assert!(reg.list().is_empty());
613
614        reg.register(TestCap { name: "A" });
615        reg.register(TestCap { name: "B" });
616        reg.register(TestCap { name: "C" });
617
618        let list = reg.list();
619        assert_eq!(list.len(), 3);
620        assert!(list.contains(&"A"));
621        assert!(list.contains(&"B"));
622        assert!(list.contains(&"C"));
623    }
624
625    #[test]
626    fn test_capability_error_display() {
627        let err = CapabilityError::InvalidArgs("missing field `path`".into());
628        assert!(err.to_string().contains("invalid arguments"));
629
630        let err = CapabilityError::PermissionDenied("/etc/shadow".into());
631        assert!(err.to_string().contains("blocked"));
632
633        let err = CapabilityError::NotFound("/tmp/nonexistent.txt".into());
634        assert!(err.to_string().contains("file not found"));
635
636        let err = CapabilityError::Git("fatal: not a repository".into());
637        assert!(err.to_string().contains("git error"));
638
639        let err = CapabilityError::Internal("unexpected state".into());
640        assert!(err.to_string().contains("internal error"));
641    }
642
643    #[test]
644    fn test_capability_error_from_io() {
645        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
646        let cap_err: CapabilityError = io_err.into();
647        assert!(matches!(cap_err, CapabilityError::Io(_)));
648        assert!(cap_err.to_string().contains("io error"));
649    }
650
651    #[test]
652    fn test_capability_error_debug_format() {
653        let err = CapabilityError::InvalidArgs("test".into());
654        let debug = format!("{:?}", err);
655        assert!(debug.contains("InvalidArgs"));
656    }
657
658    // ── Output constructors ──────────────────────────────────────────
659
660    #[test]
661    fn test_output_ok_constructor() {
662        let out = Output::ok("done".into());
663        assert_eq!(out.status, "ok");
664        assert_eq!(out.output, "done");
665        assert!(out.error.is_none());
666        assert!(out.data.is_none());
667        assert!(out.backup_path.is_none());
668        assert!(out.artifacts.is_empty());
669    }
670
671    #[test]
672    fn test_output_error_constructor() {
673        let out = Output::error("failed".into(), "not found".into());
674        assert_eq!(out.status, "error");
675        assert_eq!(out.output, "failed");
676        assert_eq!(out.error.as_deref(), Some("not found"));
677    }
678
679    #[test]
680    fn test_output_to_json() {
681        let out = Output::ok("test".into());
682        let json = out.to_json();
683        assert_eq!(json["status"], "ok");
684        assert_eq!(json["output"], "test");
685        assert!(json["error"].is_null());
686    }
687
688    // ── TypedCapability blanket impl ─────────────────────────────────
689
690    /// A test TypedCapability implementation.
691    struct TypedTestCap;
692
693    impl TypedCapability for TypedTestCap {
694        type Args = serde_json::Value;
695
696        fn name(&self) -> &'static str {
697            "TypedTest"
698        }
699
700        fn description(&self) -> &'static str {
701            "typed test capability"
702        }
703
704        fn schema(&self) -> Value {
705            serde_json::json!({"type": "object"})
706        }
707
708        fn execute(
709            &self,
710            args: Self::Args,
711            _ctx: &Context,
712        ) -> std::result::Result<Output, CapabilityError> {
713            Ok(Output::ok(format!("typed: {}", args)))
714        }
715    }
716
717    #[test]
718    fn test_typed_capability_blanket_impl_bridge() {
719        // Register a TypedCapability as a Capability via blanket impl
720        let mut reg = CapabilityRegistry::new();
721        reg.register(TypedTestCap);
722
723        // Dynamic dispatch through &dyn Capability
724        let cap = reg.get("TypedTest").unwrap();
725        assert_eq!(cap.name(), "TypedTest");
726
727        let result = cap.execute(
728            &serde_json::json!("hello"),
729            &Context::new(false, "test".into()),
730        );
731        assert!(result.is_ok());
732        let output = result.unwrap();
733        assert_eq!(output.status, "ok");
734    }
735
736    #[test]
737    fn test_typed_capability_validate_always_ok() {
738        // The blanket impl's validate() always returns Ok — deserialization
739        // is validation, happening in execute().
740        let cap = TypedTestCap;
741        let result = Capability::validate(&cap, &serde_json::json!({}));
742        assert!(result.is_ok());
743    }
744}