Skip to main content

hm_exec/
request.rs

1//! Inputs to a backend run: a typed [`Plan`], source location, env, options.
2
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use hm_pipeline_ir::PipelineGraph;
8use hm_plugin_protocol::events::PlanSummary;
9
10/// A rendered, ready-to-run pipeline.
11///
12/// Carries both the typed graph (for client-scheduling backends like local) and
13/// the verbatim IR JSON (for forwarding backends like cloud — the server must
14/// receive exactly what the DSL emitted). Parsed once, before any backend is
15/// touched.
16#[derive(Debug, Clone)]
17pub struct Plan {
18    pub graph: PipelineGraph,
19    pub ir_json: String,
20    pub summary: PlanSummary,
21}
22
23impl Plan {
24    /// Parse verbatim IR JSON into a typed plan, retaining the original string.
25    ///
26    /// # Errors
27    /// Returns [`crate::BackendError::Rejected`] when `ir_json` is not valid
28    /// pipeline IR JSON.
29    pub fn parse(ir_json: String) -> crate::Result<Self> {
30        let graph: PipelineGraph = serde_json::from_slice(ir_json.as_bytes()).map_err(|e| {
31            crate::BackendError::Rejected {
32                code: "invalid_ir".into(),
33                message: format!("could not parse pipeline IR: {e}"),
34            }
35        })?;
36        let summary = summarize(&graph);
37        Ok(Self {
38            graph,
39            ir_json,
40            summary,
41        })
42    }
43}
44
45/// Build a [`PlanSummary`] from a parsed graph.
46///
47/// - `step_count` = number of nodes.
48/// - `chain_count` = number of linear `BuildsIn` chains, delegated to the
49///   authoritative implementation in `local::scheduler::chain_count`.
50/// - `default_runner` = `"docker"` (matches the scheduler's
51///   `unwrap_or("docker")` fallback).
52fn summarize(graph: &PipelineGraph) -> PlanSummary {
53    PlanSummary {
54        step_count: graph.node_count(),
55        chain_count: crate::local::chain_count(graph.dag()),
56        default_runner: "docker".to_string(),
57    }
58}
59
60/// Git metadata for the worktree being submitted.
61#[derive(Debug, Clone)]
62pub struct SourceMeta {
63    pub branch: String,
64    pub commit: String,
65    pub message: Option<String>,
66}
67
68/// Per-run execution options threaded through from the CLI flags.
69#[derive(Debug, Clone, Default)]
70pub struct RunOptions {
71    pub no_cache: bool,
72    pub timeout: Option<Duration>,
73    /// `false` == cloud `--no-watch` (submit, emit `BuildAccepted`, return).
74    pub watch: bool,
75    /// When `true`, step failures do not cancel the entire build.
76    /// Direct dependents are still skipped, but independent branches
77    /// continue running.
78    pub keep_going: bool,
79}
80
81/// All inputs needed to start a build on any [`crate::ExecutionBackend`].
82#[derive(Debug, Clone)]
83pub struct RunRequest {
84    pub plan: Plan,
85    pub repo_root: PathBuf,
86    pub pipeline_slug: String,
87    pub env: BTreeMap<String, String>,
88    pub source: SourceMeta,
89    pub options: RunOptions,
90}
91
92#[cfg(test)]
93#[allow(clippy::unwrap_used, clippy::expect_used)]
94mod tests {
95    use super::*;
96
97    /// Minimal valid empty `PipelineGraph` serialized to JSON.
98    ///
99    /// `PipelineGraph` uses petgraph-serde for its `graph` field; the
100    /// wire shape is `{nodes, node_holes, edge_property, edges}`.
101    /// The outer wrapper adds `version` (defaulted to `"0"`) and an
102    /// optional `default_image`.  `"steps"` is NOT a valid field.
103    const EMPTY_IR: &str = r#"{"version":"0","graph":{"nodes":[],"node_holes":[],"edge_property":"directed","edges":[]}}"#;
104
105    #[test]
106    fn plan_keeps_verbatim_json_and_typed_graph() {
107        let json = EMPTY_IR.to_string();
108        let plan = Plan::parse(json.clone()).expect("parse");
109        assert_eq!(plan.ir_json, json); // verbatim, byte-for-byte
110        assert_eq!(plan.summary.step_count, 0); // derived from the graph
111    }
112
113    #[test]
114    fn plan_summary_matches_scheduler_for_single_chain() {
115        // A graph with two nodes connected by a single BuildsIn edge forms one chain.
116        let json = r#"{
117            "version": "0",
118            "default_image": "ubuntu:24.04",
119            "graph": {
120                "nodes": [
121                    {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}},
122                    {"step": {"key": "b", "cmd": "echo b"}, "env": {}}
123                ],
124                "node_holes": [],
125                "edge_property": "directed",
126                "edges": [[0, 1, "builds_in"]]
127            }
128        }"#
129        .to_string();
130
131        let plan = Plan::parse(json.clone()).expect("parse");
132        assert_eq!(plan.summary.step_count, 2);
133        assert_eq!(plan.summary.chain_count, 1);
134        assert_eq!(plan.summary.default_runner, "docker");
135        // ir_json is verbatim
136        assert_eq!(plan.ir_json, json);
137    }
138
139    #[test]
140    fn plan_summary_counts_two_independent_chains() {
141        // Two root nodes with no edges → two separate chains.
142        let json = r#"{
143            "version": "0",
144            "graph": {
145                "nodes": [
146                    {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}},
147                    {"step": {"key": "b", "cmd": "echo b", "image": "ubuntu:24.04"}, "env": {}}
148                ],
149                "node_holes": [],
150                "edge_property": "directed",
151                "edges": []
152            }
153        }"#
154        .to_string();
155
156        let plan = Plan::parse(json).expect("parse");
157        assert_eq!(plan.summary.step_count, 2);
158        assert_eq!(plan.summary.chain_count, 2);
159    }
160
161    #[test]
162    fn invalid_ir_returns_rejected_error() {
163        let err = Plan::parse("not json at all".to_string()).unwrap_err();
164        assert!(matches!(err, crate::BackendError::Rejected { .. }));
165        let msg = err.to_string();
166        assert!(msg.contains("invalid_ir"));
167    }
168
169    #[test]
170    fn run_options_default_is_zero() {
171        let opts = RunOptions::default();
172        assert!(!opts.no_cache);
173        assert!(opts.timeout.is_none());
174        assert!(!opts.watch);
175    }
176}