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}