Skip to main content

harmont_cli/runner/
mod.rs

1//! Static runner interface.
2//!
3//! This module replaces the old WASM plugin system with a static DI
4//! approach. Step executors implement [`StepRunner`]; output formatters
5//! implement [`OutputRenderer`]. A [`RunnerRegistry`] maps runner names
6//! to concrete implementations at startup.
7
8use std::collections::HashMap;
9use std::fmt;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use anyhow::Result;
15use hm_plugin_protocol::{BuildEvent, ExecutorInput, StepResult};
16use tokio_util::sync::CancellationToken;
17
18use crate::orchestrator::archive::ArchiveStore;
19use crate::orchestrator::docker_client::DockerClient;
20use crate::orchestrator::events::EventBus;
21
22pub mod docker;
23
24/// Shared context threaded into every runner invocation.
25///
26/// Replaces the monolithic `OrchestratorState` that the old plugin
27/// system passed as opaque host memory. All fields are cheaply
28/// cloneable (`Arc` / `CancellationToken` / `DockerClient`).
29#[derive(Clone, Debug)]
30pub struct RunContext {
31    pub docker: DockerClient,
32    pub event_bus: Arc<EventBus>,
33    pub archives: Arc<ArchiveStore>,
34    pub cancel: CancellationToken,
35}
36
37/// Async trait implemented by step executors (e.g. the Docker runner).
38///
39/// Each runner is identified by a string [`Self::name`] that pipeline
40/// authors reference in their step definitions.
41///
42/// The `execute` method returns a boxed future so the trait remains
43/// dyn-compatible (async fn in trait is not object-safe).
44pub trait StepRunner: Send + Sync + fmt::Debug {
45    /// Unique name for this runner (e.g. `"docker"`).
46    fn name(&self) -> &str;
47
48    /// Execute a single pipeline step.
49    ///
50    /// # Errors
51    ///
52    /// Implementations should return `Err` for infrastructure failures
53    /// (container boot failure, network error, etc.). A non-zero exit
54    /// code from the user command is **not** an error — it is reported
55    /// via [`StepResult::exit_code`].
56    fn execute(
57        &self,
58        ctx: &RunContext,
59        input: ExecutorInput,
60    ) -> Pin<Box<dyn Future<Output = Result<StepResult>> + Send + '_>>;
61}
62
63/// Synchronous observer of [`BuildEvent`]s.
64///
65/// Implementations format events for human consumption (progress bars,
66/// coloured log lines) or machine consumption (JSON-lines).
67pub trait OutputRenderer: Send + fmt::Debug {
68    /// Called once per event in emission order.
69    fn on_event(&mut self, event: &BuildEvent);
70}
71
72/// Maps runner names to [`StepRunner`] implementations.
73///
74/// Constructed once at startup and shared immutably for the duration
75/// of the run.
76#[derive(Default)]
77pub struct RunnerRegistry {
78    runners: HashMap<String, Arc<dyn StepRunner>>,
79    default: Option<String>,
80}
81
82impl RunnerRegistry {
83    /// Create an empty registry.
84    #[must_use]
85    pub fn new() -> Self {
86        Self {
87            runners: HashMap::new(),
88            default: None,
89        }
90    }
91
92    /// Register a runner. When `is_default` is true the runner's name
93    /// becomes the fallback used by [`Self::resolve`] when no explicit
94    /// name is given.
95    pub fn register(&mut self, runner: Arc<dyn StepRunner>, is_default: bool) {
96        let name = runner.name().to_owned();
97        if is_default {
98            self.default = Some(name.clone());
99        }
100        self.runners.insert(name, runner);
101    }
102
103    /// Look up a runner by name, falling back to the default when
104    /// `name` is `None`.
105    #[must_use]
106    pub fn resolve(&self, name: Option<&str>) -> Option<Arc<dyn StepRunner>> {
107        let key = name.or(self.default.as_deref())?;
108        self.runners.get(key).cloned()
109    }
110
111    /// The name of the current default runner, if one has been set.
112    #[must_use]
113    pub fn default_runner_name(&self) -> Option<&str> {
114        self.default.as_deref()
115    }
116
117    /// Sorted list of all registered runner names.
118    #[must_use]
119    pub fn runner_names(&self) -> Vec<&str> {
120        let mut names: Vec<&str> = self.runners.keys().map(String::as_str).collect();
121        names.sort_unstable();
122        names
123    }
124}
125
126impl fmt::Debug for RunnerRegistry {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.debug_struct("RunnerRegistry")
129            .field("runners", &self.runners.keys().collect::<Vec<_>>())
130            .field("default", &self.default)
131            .finish()
132    }
133}
134
135#[cfg(test)]
136#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
137mod tests {
138    use super::*;
139
140    /// Minimal stub runner for unit tests.
141    #[derive(Debug)]
142    struct StubRunner {
143        runner_name: String,
144    }
145
146    impl StubRunner {
147        fn new(name: &str) -> Self {
148            Self {
149                runner_name: name.to_owned(),
150            }
151        }
152    }
153
154    impl StepRunner for StubRunner {
155        fn name(&self) -> &str {
156            &self.runner_name
157        }
158
159        fn execute(
160            &self,
161            _ctx: &RunContext,
162            _input: ExecutorInput,
163        ) -> Pin<Box<dyn Future<Output = Result<StepResult>> + Send + '_>> {
164            Box::pin(async {
165                Ok(StepResult {
166                    exit_code: 0,
167                    committed_snapshot: None,
168                    artifacts: vec![],
169                })
170            })
171        }
172    }
173
174    #[test]
175    fn resolve_by_name() {
176        let mut reg = RunnerRegistry::new();
177        reg.register(Arc::new(StubRunner::new("docker")), false);
178        reg.register(Arc::new(StubRunner::new("local")), false);
179
180        let runner = reg.resolve(Some("docker")).unwrap();
181        assert_eq!(runner.name(), "docker");
182
183        let runner = reg.resolve(Some("local")).unwrap();
184        assert_eq!(runner.name(), "local");
185
186        assert!(reg.resolve(Some("nope")).is_none());
187    }
188
189    #[test]
190    fn resolve_default() {
191        let mut reg = RunnerRegistry::new();
192        reg.register(Arc::new(StubRunner::new("docker")), true);
193        reg.register(Arc::new(StubRunner::new("local")), false);
194
195        // `None` name falls back to default.
196        let runner = reg.resolve(None).unwrap();
197        assert_eq!(runner.name(), "docker");
198        assert_eq!(reg.default_runner_name(), Some("docker"));
199    }
200
201    #[test]
202    fn no_default_returns_none() {
203        let mut reg = RunnerRegistry::new();
204        reg.register(Arc::new(StubRunner::new("docker")), false);
205
206        assert!(reg.resolve(None).is_none());
207        assert!(reg.default_runner_name().is_none());
208    }
209
210    #[test]
211    fn runner_names_sorted() {
212        let mut reg = RunnerRegistry::new();
213        reg.register(Arc::new(StubRunner::new("zeta")), false);
214        reg.register(Arc::new(StubRunner::new("alpha")), false);
215        reg.register(Arc::new(StubRunner::new("mid")), false);
216
217        assert_eq!(reg.runner_names(), vec!["alpha", "mid", "zeta"]);
218    }
219
220    #[test]
221    fn debug_impl() {
222        let reg = RunnerRegistry::new();
223        // Just ensure it doesn't panic.
224        let _ = format!("{reg:?}");
225    }
226}