Skip to main content

cruxx_script/
step_runner.rs

1/// Step runner registry — maps step kinds to capability-declared synchronous runners.
2///
3/// This is separate from [`HandlerRegistry`] (async, closure-based) and serves
4/// as an auditable catalog of built-in step kinds with their required capabilities.
5use anyhow::Result;
6
7/// Capabilities a step runner may require from the execution environment.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum RunnerCapability {
10    Shell,
11    Filesystem,
12    Git,
13    JsonMutation,
14    LlmCall,
15    OutputPropagation,
16}
17
18/// Input context passed to a step runner at execution time.
19pub struct StepContext {
20    pub alias: String,
21    pub config: serde_json::Value,
22}
23
24/// Output produced by a step runner.
25pub struct StepOutput {
26    pub value: serde_json::Value,
27}
28
29/// Trait implemented by every built-in step runner.
30pub trait StepRunner: Send + Sync {
31    fn kind(&self) -> &'static str;
32    fn required_capabilities(&self) -> Vec<RunnerCapability>;
33    fn run(&self, ctx: StepContext) -> Result<StepOutput>;
34}
35
36/// Registry of step runners, auditable by kind and capability.
37///
38/// Starts empty; call [`register_builtin_runners`](Self::register_builtin_runners)
39/// to populate with the five built-in kinds.
40pub struct StepRunnerRegistry {
41    entries: Vec<Box<dyn StepRunner>>,
42}
43
44impl StepRunnerRegistry {
45    pub fn new() -> Self {
46        Self {
47            entries: Vec::new(),
48        }
49    }
50
51    /// Register a custom step runner.
52    pub fn register(&mut self, runner: Box<dyn StepRunner>) {
53        self.entries.push(runner);
54    }
55
56    /// Look up a runner by kind string. Returns `None` if no match.
57    pub fn get(&self, kind: &str) -> Option<&dyn StepRunner> {
58        self.entries
59            .iter()
60            .find(|r| r.kind() == kind)
61            .map(|r| r.as_ref())
62    }
63
64    /// Return all registered `(kind, capabilities)` pairs for auditing.
65    pub fn list(&self) -> Vec<(&str, Vec<RunnerCapability>)> {
66        self.entries
67            .iter()
68            .map(|r| (r.kind(), r.required_capabilities()))
69            .collect()
70    }
71
72    /// Populate the registry with the five built-in step runners.
73    pub fn register_builtin_runners(&mut self) {
74        self.register(Box::new(ShellRunner));
75        self.register(Box::new(FsWriteRunner));
76        self.register(Box::new(GitCommitRunner));
77        self.register(Box::new(JsonUpdateRunner));
78        self.register(Box::new(LlmCallRunner));
79    }
80}
81
82impl Default for StepRunnerRegistry {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88// ── Built-in runner stubs ──────────────────────────────────────────────────
89
90pub struct ShellRunner;
91impl StepRunner for ShellRunner {
92    fn kind(&self) -> &'static str {
93        "shell"
94    }
95    fn required_capabilities(&self) -> Vec<RunnerCapability> {
96        vec![RunnerCapability::Shell]
97    }
98    fn run(&self, _ctx: StepContext) -> Result<StepOutput> {
99        Ok(StepOutput {
100            value: serde_json::Value::Null,
101        })
102    }
103}
104
105pub struct FsWriteRunner;
106impl StepRunner for FsWriteRunner {
107    fn kind(&self) -> &'static str {
108        "fs-write"
109    }
110    fn required_capabilities(&self) -> Vec<RunnerCapability> {
111        vec![RunnerCapability::Filesystem]
112    }
113    fn run(&self, _ctx: StepContext) -> Result<StepOutput> {
114        Ok(StepOutput {
115            value: serde_json::Value::Null,
116        })
117    }
118}
119
120pub struct GitCommitRunner;
121impl StepRunner for GitCommitRunner {
122    fn kind(&self) -> &'static str {
123        "git-commit"
124    }
125    fn required_capabilities(&self) -> Vec<RunnerCapability> {
126        vec![RunnerCapability::Git, RunnerCapability::Filesystem]
127    }
128    fn run(&self, _ctx: StepContext) -> Result<StepOutput> {
129        Ok(StepOutput {
130            value: serde_json::Value::Null,
131        })
132    }
133}
134
135pub struct JsonUpdateRunner;
136impl StepRunner for JsonUpdateRunner {
137    fn kind(&self) -> &'static str {
138        "json-update"
139    }
140    fn required_capabilities(&self) -> Vec<RunnerCapability> {
141        vec![RunnerCapability::JsonMutation, RunnerCapability::Filesystem]
142    }
143    fn run(&self, _ctx: StepContext) -> Result<StepOutput> {
144        Ok(StepOutput {
145            value: serde_json::Value::Null,
146        })
147    }
148}
149
150pub struct LlmCallRunner;
151impl StepRunner for LlmCallRunner {
152    fn kind(&self) -> &'static str {
153        "llm-call"
154    }
155    fn required_capabilities(&self) -> Vec<RunnerCapability> {
156        vec![
157            RunnerCapability::LlmCall,
158            RunnerCapability::OutputPropagation,
159        ]
160    }
161    fn run(&self, _ctx: StepContext) -> Result<StepOutput> {
162        Ok(StepOutput {
163            value: serde_json::Value::Null,
164        })
165    }
166}
167
168// ── Conformance helper ─────────────────────────────────────────────────────
169
170/// Assert the basic contract for any `StepRunner` implementation.
171///
172/// Verifies:
173/// - `kind()` is non-empty
174/// - `run()` does not panic
175#[cfg(test)]
176pub fn assert_step_runner_contract(runner: &dyn StepRunner) {
177    assert!(!runner.kind().is_empty(), "runner kind must be non-empty");
178    let ctx = StepContext {
179        alias: "test".to_string(),
180        config: serde_json::Value::Null,
181    };
182    let _ = runner.run(ctx); // must not panic
183}
184
185#[cfg(test)]
186mod step_runner_registry_tests {
187    use super::*;
188
189    #[test]
190    fn registry_get_unknown_kind_returns_none() {
191        let registry = StepRunnerRegistry::new();
192        assert!(registry.get("unknown").is_none());
193    }
194
195    #[test]
196    fn registry_list_includes_all_registered_kinds() {
197        let mut registry = StepRunnerRegistry::new();
198        registry.register_builtin_runners();
199        let kinds: Vec<&str> = registry.list().iter().map(|(k, _)| *k).collect();
200        assert!(kinds.contains(&"shell"));
201        assert!(kinds.contains(&"fs-write"));
202        assert!(kinds.contains(&"git-commit"));
203        assert!(kinds.contains(&"json-update"));
204        assert!(kinds.contains(&"llm-call"));
205    }
206
207    #[test]
208    fn shell_runner_declares_shell_capability() {
209        let mut registry = StepRunnerRegistry::new();
210        registry.register_builtin_runners();
211        let (_, caps) = registry
212            .list()
213            .into_iter()
214            .find(|(k, _)| *k == "shell")
215            .unwrap();
216        assert!(caps.contains(&RunnerCapability::Shell));
217    }
218
219    #[test]
220    fn llm_call_runner_declares_output_propagation() {
221        let mut registry = StepRunnerRegistry::new();
222        registry.register_builtin_runners();
223        let (_, caps) = registry
224            .list()
225            .into_iter()
226            .find(|(k, _)| *k == "llm-call")
227            .unwrap();
228        assert!(caps.contains(&RunnerCapability::LlmCall));
229        assert!(caps.contains(&RunnerCapability::OutputPropagation));
230    }
231
232    #[test]
233    fn all_builtin_runners_satisfy_contract() {
234        let mut registry = StepRunnerRegistry::new();
235        registry.register_builtin_runners();
236        for (kind, _) in registry.list() {
237            let runner = registry
238                .get(kind)
239                .expect("runner must be findable by its own kind");
240            assert_step_runner_contract(runner);
241        }
242    }
243}