cruxx_script/
step_runner.rs1use anyhow::Result;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum RunnerCapability {
10 Shell,
11 Filesystem,
12 Git,
13 JsonMutation,
14 LlmCall,
15 OutputPropagation,
16}
17
18pub struct StepContext {
20 pub alias: String,
21 pub config: serde_json::Value,
22}
23
24pub struct StepOutput {
26 pub value: serde_json::Value,
27}
28
29pub 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
36pub 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 pub fn register(&mut self, runner: Box<dyn StepRunner>) {
53 self.entries.push(runner);
54 }
55
56 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 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 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
88pub 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#[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); }
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}