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}