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 (case-insensitive).
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 if let Some(cap) = self.capabilities.get(name) {
183 return Some(cap.as_ref());
184 }
185 let name_lower = name.to_lowercase();
186 for (key, cap) in &self.capabilities {
187 if key.to_lowercase() == name_lower {
188 return Some(cap.as_ref());
189 }
190 }
191 None
192 }
193
194 /// Returns the names of all registered capabilities.
195 #[must_use]
196 pub fn list(&self) -> Vec<&str> {
197 self.capabilities.keys().map(|s| s.as_str()).collect()
198 }
199}
200
201impl Default for CapabilityRegistry {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used)]
209mod tests {
210 use super::*;
211 use serde_json::Value;
212
213 /// A minimal test capability that echoes its name.
214 struct TestCap {
215 name: &'static str,
216 }
217
218 impl Capability for TestCap {
219 fn name(&self) -> &'static str {
220 self.name
221 }
222 fn description(&self) -> &'static str {
223 "test capability"
224 }
225 fn schema(&self) -> Value {
226 serde_json::json!({})
227 }
228 fn validate(&self, _args: &Value) -> crate::Result<()> {
229 Ok(())
230 }
231 fn execute(&self, _args: &Value, _ctx: &Context) -> crate::Result<Output> {
232 Ok(Output {
233 success: true,
234 data: serde_json::json!({}),
235 message: None,
236 })
237 }
238 }
239
240 #[test]
241 fn test_registry_register_and_get() {
242 let mut reg = CapabilityRegistry::new();
243 reg.register(TestCap { name: "Alpha" });
244
245 let cap = reg.get("Alpha");
246 assert!(cap.is_some(), "Should find registered capability");
247 assert_eq!(cap.unwrap().name(), "Alpha");
248 }
249
250 #[test]
251 fn test_registry_duplicate_name_replaces() {
252 let mut reg = CapabilityRegistry::new();
253 reg.register(TestCap { name: "Beta" });
254 reg.register(TestCap { name: "Beta" }); // second registration replaces
255
256 let cap = reg.get("Beta");
257 assert!(
258 cap.is_some(),
259 "Should still find capability after duplicate registration"
260 );
261 assert_eq!(cap.unwrap().name(), "Beta");
262 }
263
264 #[test]
265 fn test_registry_case_insensitive_lookup() {
266 let mut reg = CapabilityRegistry::new();
267 reg.register(TestCap { name: "ShellExec" });
268
269 // Exact match
270 assert!(reg.get("ShellExec").is_some());
271 // Case-insensitive match
272 assert!(
273 reg.get("shellexec").is_some(),
274 "Case-insensitive lookup should find ShellExec"
275 );
276 assert!(
277 reg.get("SHELLEXEC").is_some(),
278 "Uppercase lookup should find ShellExec"
279 );
280 assert!(
281 reg.get("ShellExec").is_some(),
282 "Exact-case lookup should find ShellExec"
283 );
284 }
285
286 #[test]
287 fn test_registry_unregistered_lookup_returns_none() {
288 let mut reg = CapabilityRegistry::new();
289 reg.register(TestCap { name: "Delta" });
290
291 assert!(reg.get("NoSuchCap").is_none());
292 assert!(reg.get("").is_none());
293 // Unregistered even with case-insensitive match
294 assert!(reg.get("gamma").is_none());
295 }
296
297 #[test]
298 fn test_registry_list() {
299 let mut reg = CapabilityRegistry::new();
300 assert!(reg.list().is_empty());
301
302 reg.register(TestCap { name: "A" });
303 reg.register(TestCap { name: "B" });
304 reg.register(TestCap { name: "C" });
305
306 let list = reg.list();
307 assert_eq!(list.len(), 3);
308 assert!(list.contains(&"A"));
309 assert!(list.contains(&"B"));
310 assert!(list.contains(&"C"));
311 }
312}