runtimo_core/capability.rs
1//! 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
7use crate::Result;
8use serde_json::Value;
9use std::path::PathBuf;
10
11/// Context provided to capabilities during execution.
12///
13/// Carries execution metadata such as dry-run mode, the owning job ID,
14/// and the working directory for relative path resolution.
15///
16/// Use [`Context::new`] to create instances — this ensures consistent
17/// field initialization across CLI, executor, and daemon code paths.
18#[allow(clippy::exhaustive_structs)] // fields are write-through API contract
19pub struct Context {
20 /// If true, the capability should not perform side effects.
21 pub dry_run: bool,
22 /// The job ID that owns this execution.
23 pub job_id: String,
24 /// Working directory for relative path resolution.
25 pub working_dir: PathBuf,
26}
27
28impl Context {
29 /// Creates a new execution context.
30 ///
31 /// Uses `std::env::current_dir()` as the default working directory.
32 /// The caller should override `working_dir` if an explicit directory
33 /// is known (e.g., from daemon dispatch parameters).
34 #[must_use]
35 pub fn new(dry_run: bool, job_id: String) -> Self {
36 Self {
37 dry_run,
38 job_id,
39 working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
40 }
41 }
42
43 /// Creates a new execution context with an explicit working directory.
44 #[must_use]
45 pub fn with_working_dir(dry_run: bool, job_id: String, working_dir: PathBuf) -> Self {
46 Self {
47 dry_run,
48 job_id,
49 working_dir,
50 }
51 }
52}
53
54/// Output from capability execution.
55///
56/// Returned by every [`Capability::execute`] call. The `data` field holds
57/// structured JSON output, while `message` provides a human-readable summary.
58#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
59#[allow(clippy::exhaustive_structs)] // fields are write-through API contract
60pub struct Output {
61 /// Whether the capability completed successfully.
62 pub success: bool,
63 /// Structured output data (JSON).
64 pub data: Value,
65 /// Human-readable status or error message.
66 pub message: Option<String>,
67}
68
69/// The Capability trait — all capabilities must implement this.
70///
71/// Each capability defines its name, argument schema, validation logic,
72/// and execution behavior. The executor calls these methods in order:
73/// `name()` → `schema()` → `validate()` → `execute()`.
74///
75/// # Example
76///
77/// ```rust
78/// use runtimo_core::capability::{Capability, Context, Output};
79/// use runtimo_core::Result;
80/// use serde_json::Value;
81///
82/// struct Echo;
83///
84/// impl Capability for Echo {
85/// fn name(&self) -> &'static str { "Echo" }
86/// fn description(&self) -> &'static str { "Echo back arguments" }
87/// fn schema(&self) -> Value { serde_json::json!({"type":"object"}) }
88/// fn validate(&self, _args: &Value) -> Result<()> { Ok(()) }
89/// fn execute(&self, args: &Value, _ctx: &Context) -> Result<Output> {
90/// Ok(Output { success: true, data: args.clone(), message: None })
91/// }
92/// }
93/// ```
94pub trait Capability: Send + Sync {
95 /// Returns the capability name (e.g., `"FileRead"`, `"FileWrite"`).
96 ///
97 /// This name is used for registry lookups and WAL event tagging.
98 fn name(&self) -> &'static str;
99
100 /// Returns a one-line human-readable description of what this capability does.
101 ///
102 /// Used by the CLI `list` command and `--help` output to help users
103 /// discover available capabilities.
104 fn description(&self) -> &'static str;
105
106 /// Returns the JSON Schema for the capability's arguments.
107 ///
108 /// The schema is used by [`Capability::validate`] and by the CLI
109 /// to generate `--help` output for each capability.
110 fn schema(&self) -> Value;
111
112 /// Validates the arguments against the schema.
113 ///
114 /// Implementations should deserialize `args` into their typed args struct
115 /// and perform semantic checks (e.g., path traversal rejection).
116 ///
117 /// # Errors
118 ///
119 /// Returns [`Error::SchemaValidationFailed`](crate::Error::SchemaValidationFailed)
120 /// if arguments are malformed or semantically invalid.
121 fn validate(&self, args: &Value) -> Result<()>;
122
123 /// Executes the capability with the given arguments and context.
124 ///
125 /// This is called after `validate()` passes. Implementations should
126 /// perform the actual work and return an [`Output`].
127 ///
128 /// # Errors
129 ///
130 /// Returns [`Error::ExecutionFailed`](crate::Error::ExecutionFailed)
131 /// if the operation cannot be completed.
132 fn execute(&self, args: &Value, ctx: &Context) -> Result<Output>;
133}
134
135/// Registry of available capabilities.
136///
137/// Stores capabilities by name and provides lookup, listing, and registration.
138///
139/// # Example
140///
141/// ```rust,ignore
142/// use runtimo_core::{CapabilityRegistry, FileRead, FileWrite};
143/// use std::path::PathBuf;
144///
145/// let mut registry = CapabilityRegistry::new();
146/// registry.register(FileRead);
147/// registry.register(FileWrite::new(PathBuf::from("/tmp/backups")).unwrap());
148///
149/// assert!(registry.get("FileRead").is_some());
150/// let caps = registry.list();
151/// assert_eq!(caps.len(), 2);
152/// assert!(caps.contains(&"FileRead"));
153/// assert!(caps.contains(&"FileWrite"));
154/// ```
155pub struct CapabilityRegistry {
156 capabilities: std::collections::HashMap<String, Box<dyn Capability>>,
157}
158
159impl CapabilityRegistry {
160 /// Creates an empty registry.
161 #[must_use]
162 pub fn new() -> Self {
163 Self {
164 capabilities: std::collections::HashMap::new(),
165 }
166 }
167
168 /// Registers a capability in the registry.
169 ///
170 /// The capability is stored under its [`Capability::name`]. If a capability
171 /// with the same name already exists, it is replaced.
172 pub fn register<C: Capability + 'static>(&mut self, capability: C) {
173 let name = capability.name().to_string();
174 self.capabilities.insert(name, Box::new(capability));
175 }
176
177 /// Looks up a capability by name.
178 ///
179 /// Returns `None` if no capability with the given name is registered.
180 #[must_use]
181 pub fn get(&self, name: &str) -> Option<&dyn Capability> {
182 self.capabilities.get(name).map(|c| c.as_ref())
183 }
184
185 /// Returns the names of all registered capabilities.
186 #[must_use]
187 pub fn list(&self) -> Vec<&str> {
188 self.capabilities.keys().map(|s| s.as_str()).collect()
189 }
190}
191
192impl Default for CapabilityRegistry {
193 fn default() -> Self {
194 Self::new()
195 }
196}