Skip to main content

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}