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}