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)
431            .map_err(|e| crate::Error::ExecutionFailed(e.to_string()))
432    }
433}
434
435/// Registry of available capabilities.
436///
437/// Stores capabilities by name and provides lookup, listing, and registration.
438///
439/// # Example
440///
441/// ```rust,ignore
442/// use runtimo_core::{CapabilityRegistry, FileRead, FileWrite};
443/// use std::path::PathBuf;
444///
445/// let mut registry = CapabilityRegistry::new();
446/// registry.register(FileRead);
447/// registry.register(FileWrite::new(PathBuf::from("/tmp/backups")).unwrap());
448///
449/// assert!(registry.get("FileRead").is_some());
450/// let caps = registry.list();
451/// assert_eq!(caps.len(), 2);
452/// assert!(caps.contains(&"FileRead"));
453/// assert!(caps.contains(&"FileWrite"));
454/// ```
455pub struct CapabilityRegistry {
456    capabilities: std::collections::HashMap<String, Box<dyn Capability>>,
457}
458
459impl CapabilityRegistry {
460    /// Creates an empty registry.
461    #[must_use]
462    pub fn new() -> Self {
463        Self {
464            capabilities: std::collections::HashMap::new(),
465        }
466    }
467
468    /// Registers a capability in the registry.
469    ///
470    /// The capability is stored under its [`Capability::name`]. If a capability
471    /// with the same name already exists, it is replaced.
472    pub fn register<C: Capability + 'static>(&mut self, capability: C) {
473        let name = capability.name().to_string();
474        self.capabilities.insert(name, Box::new(capability));
475    }
476
477    /// Looks up a capability by name (case-insensitive).
478    ///
479    /// Returns `None` if no capability with the given name is registered.
480    #[must_use]
481    pub fn get(&self, name: &str) -> Option<&dyn Capability> {
482        if let Some(cap) = self.capabilities.get(name) {
483            return Some(cap.as_ref());
484        }
485        let name_lower = name.to_lowercase();
486        for (key, cap) in &self.capabilities {
487            if key.to_lowercase() == name_lower {
488                return Some(cap.as_ref());
489            }
490        }
491        None
492    }
493
494    /// Returns the names of all registered capabilities.
495    #[must_use]
496    pub fn list(&self) -> Vec<&str> {
497        self.capabilities.keys().map(|s| s.as_str()).collect()
498    }
499}
500
501impl Default for CapabilityRegistry {
502    fn default() -> Self {
503        Self::new()
504    }
505}
506
507#[cfg(test)]
508#[allow(clippy::unwrap_used)]
509mod tests {
510    use super::*;
511    use serde_json::Value;
512
513    /// A minimal test capability that echoes its name.
514    struct TestCap {
515        name: &'static str,
516    }
517
518    impl Capability for TestCap {
519        fn name(&self) -> &'static str {
520            self.name
521        }
522        fn description(&self) -> &'static str {
523            "test capability"
524        }
525        fn schema(&self) -> Value {
526            serde_json::json!({})
527        }
528        fn validate(&self, _args: &Value) -> crate::Result<()> {
529            Ok(())
530        }
531        fn execute(&self, _args: &Value, _ctx: &Context) -> crate::Result<Output> {
532            Ok(Output::ok("test completed".into()))
533        }
534    }
535
536    #[test]
537    fn test_registry_register_and_get() {
538        let mut reg = CapabilityRegistry::new();
539        reg.register(TestCap { name: "Alpha" });
540
541        let cap = reg.get("Alpha");
542        assert!(cap.is_some(), "Should find registered capability");
543        assert_eq!(cap.unwrap().name(), "Alpha");
544    }
545
546    #[test]
547    fn test_registry_duplicate_name_replaces() {
548        let mut reg = CapabilityRegistry::new();
549        reg.register(TestCap { name: "Beta" });
550        reg.register(TestCap { name: "Beta" }); // second registration replaces
551
552        let cap = reg.get("Beta");
553        assert!(
554            cap.is_some(),
555            "Should still find capability after duplicate registration"
556        );
557        assert_eq!(cap.unwrap().name(), "Beta");
558    }
559
560    #[test]
561    fn test_registry_case_insensitive_lookup() {
562        let mut reg = CapabilityRegistry::new();
563        reg.register(TestCap { name: "ShellExec" });
564
565        // Exact match
566        assert!(reg.get("ShellExec").is_some());
567        // Case-insensitive match
568        assert!(
569            reg.get("shellexec").is_some(),
570            "Case-insensitive lookup should find ShellExec"
571        );
572        assert!(
573            reg.get("SHELLEXEC").is_some(),
574            "Uppercase lookup should find ShellExec"
575        );
576        assert!(
577            reg.get("ShellExec").is_some(),
578            "Exact-case lookup should find ShellExec"
579        );
580    }
581
582    #[test]
583    fn test_registry_unregistered_lookup_returns_none() {
584        let mut reg = CapabilityRegistry::new();
585        reg.register(TestCap { name: "Delta" });
586
587        assert!(reg.get("NoSuchCap").is_none());
588        assert!(reg.get("").is_none());
589        // Unregistered even with case-insensitive match
590        assert!(reg.get("gamma").is_none());
591    }
592
593    #[test]
594    fn test_registry_list() {
595        let mut reg = CapabilityRegistry::new();
596        assert!(reg.list().is_empty());
597
598        reg.register(TestCap { name: "A" });
599        reg.register(TestCap { name: "B" });
600        reg.register(TestCap { name: "C" });
601
602        let list = reg.list();
603        assert_eq!(list.len(), 3);
604        assert!(list.contains(&"A"));
605        assert!(list.contains(&"B"));
606        assert!(list.contains(&"C"));
607    }
608
609    #[test]
610    fn test_capability_error_display() {
611        let err = CapabilityError::InvalidArgs("missing field `path`".into());
612        assert!(err.to_string().contains("invalid arguments"));
613
614        let err = CapabilityError::PermissionDenied("/etc/shadow".into());
615        assert!(err.to_string().contains("blocked"));
616
617        let err = CapabilityError::NotFound("/tmp/nonexistent.txt".into());
618        assert!(err.to_string().contains("file not found"));
619
620        let err = CapabilityError::Git("fatal: not a repository".into());
621        assert!(err.to_string().contains("git error"));
622
623        let err = CapabilityError::Internal("unexpected state".into());
624        assert!(err.to_string().contains("internal error"));
625    }
626
627    #[test]
628    fn test_capability_error_from_io() {
629        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
630        let cap_err: CapabilityError = io_err.into();
631        assert!(matches!(cap_err, CapabilityError::Io(_)));
632        assert!(cap_err.to_string().contains("io error"));
633    }
634
635    #[test]
636    fn test_capability_error_debug_format() {
637        let err = CapabilityError::InvalidArgs("test".into());
638        let debug = format!("{:?}", err);
639        assert!(debug.contains("InvalidArgs"));
640    }
641
642    // ── Output constructors ──────────────────────────────────────────
643
644    #[test]
645    fn test_output_ok_constructor() {
646        let out = Output::ok("done".into());
647        assert_eq!(out.status, "ok");
648        assert_eq!(out.output, "done");
649        assert!(out.error.is_none());
650        assert!(out.data.is_none());
651        assert!(out.backup_path.is_none());
652        assert!(out.artifacts.is_empty());
653    }
654
655    #[test]
656    fn test_output_error_constructor() {
657        let out = Output::error("failed".into(), "not found".into());
658        assert_eq!(out.status, "error");
659        assert_eq!(out.output, "failed");
660        assert_eq!(out.error.as_deref(), Some("not found"));
661    }
662
663    #[test]
664    fn test_output_to_json() {
665        let out = Output::ok("test".into());
666        let json = out.to_json();
667        assert_eq!(json["status"], "ok");
668        assert_eq!(json["output"], "test");
669        assert!(json["error"].is_null());
670    }
671
672    // ── TypedCapability blanket impl ─────────────────────────────────
673
674    /// A test TypedCapability implementation.
675    struct TypedTestCap;
676
677    impl TypedCapability for TypedTestCap {
678        type Args = serde_json::Value;
679
680        fn name(&self) -> &'static str {
681            "TypedTest"
682        }
683
684        fn description(&self) -> &'static str {
685            "typed test capability"
686        }
687
688        fn schema(&self) -> Value {
689            serde_json::json!({"type": "object"})
690        }
691
692        fn execute(
693            &self,
694            args: Self::Args,
695            _ctx: &Context,
696        ) -> std::result::Result<Output, CapabilityError> {
697            Ok(Output::ok(format!("typed: {}", args)))
698        }
699    }
700
701    #[test]
702    fn test_typed_capability_blanket_impl_bridge() {
703        // Register a TypedCapability as a Capability via blanket impl
704        let mut reg = CapabilityRegistry::new();
705        reg.register(TypedTestCap);
706
707        // Dynamic dispatch through &dyn Capability
708        let cap = reg.get("TypedTest").unwrap();
709        assert_eq!(cap.name(), "TypedTest");
710
711        let result = cap.execute(
712            &serde_json::json!("hello"),
713            &Context::new(false, "test".into()),
714        );
715        assert!(result.is_ok());
716        let output = result.unwrap();
717        assert_eq!(output.status, "ok");
718    }
719
720    #[test]
721    fn test_typed_capability_validate_always_ok() {
722        // The blanket impl's validate() always returns Ok — deserialization
723        // is validation, happening in execute().
724        let cap = TypedTestCap;
725        let result = Capability::validate(&cap, &serde_json::json!({}));
726        assert!(result.is_ok());
727    }
728}