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    /// `owner/repo` from the worktree's git remote, when one exists. `None` for
67    /// a remoteless worktree; the cloud backend requires it to resolve the
68    /// pipeline and errors clearly when it is absent.
69    pub repo_name: Option<String>,
70}
71
72/// Per-run execution options threaded through from the CLI flags.
73#[derive(Debug, Clone, Default)]
74pub struct RunOptions {
75    pub no_cache: bool,
76    pub timeout: Option<Duration>,
77    /// `false` == cloud `--no-watch` (submit, emit `BuildAccepted`, return).
78    pub watch: bool,
79    /// When `true`, step failures do not cancel the entire build.
80    /// Direct dependents are still skipped, but independent branches
81    /// continue running.
82    pub keep_going: bool,
83}
84
85/// All inputs needed to start a build on any [`crate::ExecutionBackend`].
86#[derive(Debug, Clone)]
87pub struct RunRequest {
88    pub plan: Plan,
89    pub repo_root: PathBuf,
90    pub pipeline_slug: String,
91    pub env: BTreeMap<String, String>,
92    pub source: SourceMeta,
93    pub options: RunOptions,
94    /// When `Some`, the cloud backend submits the build directly to this
95    /// already-resolved org-global pipeline slug (via `submit_build`) instead
96    /// of resolving by repo identity (`submit_repo_build`). Set by the `hm run`
97    /// driver after it has created or looked up the pipeline for a repo the
98    /// server hasn't "discovered" (connected/pushed). `None` for the normal
99    /// repo-identity path. Ignored by non-cloud backends.
100    pub cloud_pipeline_slug: Option<String>,
101}
102
103#[cfg(test)]
104#[allow(clippy::unwrap_used, clippy::expect_used)]
105mod tests {
106    use super::*;
107
108    /// Minimal valid empty `PipelineGraph` serialized to JSON.
109    ///
110    /// `PipelineGraph` uses petgraph-serde for its `graph` field; the
111    /// wire shape is `{nodes, node_holes, edge_property, edges}`.
112    /// The outer wrapper adds `version` (defaulted to `"0"`) and an
113    /// optional `default_image`.  `"steps"` is NOT a valid field.
114    const EMPTY_IR: &str = r#"{"version":"0","graph":{"nodes":[],"node_holes":[],"edge_property":"directed","edges":[]}}"#;
115
116    #[test]
117    fn plan_keeps_verbatim_json_and_typed_graph() {
118        let json = EMPTY_IR.to_string();
119        let plan = Plan::parse(json.clone()).expect("parse");
120        assert_eq!(plan.ir_json, json); // verbatim, byte-for-byte
121        assert_eq!(plan.summary.step_count, 0); // derived from the graph
122    }
123
124    #[test]
125    fn plan_summary_matches_scheduler_for_single_chain() {
126        // A graph with two nodes connected by a single BuildsIn edge forms one chain.
127        let json = r#"{
128            "version": "0",
129            "default_image": "ubuntu:24.04",
130            "graph": {
131                "nodes": [
132                    {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}},
133                    {"step": {"key": "b", "cmd": "echo b"}, "env": {}}
134                ],
135                "node_holes": [],
136                "edge_property": "directed",
137                "edges": [[0, 1, "builds_in"]]
138            }
139        }"#
140        .to_string();
141
142        let plan = Plan::parse(json.clone()).expect("parse");
143        assert_eq!(plan.summary.step_count, 2);
144        assert_eq!(plan.summary.chain_count, 1);
145        assert_eq!(plan.summary.default_runner, "docker");
146        // ir_json is verbatim
147        assert_eq!(plan.ir_json, json);
148    }
149
150    #[test]
151    fn plan_summary_counts_two_independent_chains() {
152        // Two root nodes with no edges → two separate chains.
153        let json = r#"{
154            "version": "0",
155            "graph": {
156                "nodes": [
157                    {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}},
158                    {"step": {"key": "b", "cmd": "echo b", "image": "ubuntu:24.04"}, "env": {}}
159                ],
160                "node_holes": [],
161                "edge_property": "directed",
162                "edges": []
163            }
164        }"#
165        .to_string();
166
167        let plan = Plan::parse(json).expect("parse");
168        assert_eq!(plan.summary.step_count, 2);
169        assert_eq!(plan.summary.chain_count, 2);
170    }
171
172    #[test]
173    fn invalid_ir_returns_rejected_error() {
174        let err = Plan::parse("not json at all".to_string()).unwrap_err();
175        assert!(matches!(err, crate::BackendError::Rejected { .. }));
176        let msg = err.to_string();
177        assert!(msg.contains("invalid_ir"));
178    }
179
180    #[test]
181    fn run_options_default_is_zero() {
182        let opts = RunOptions::default();
183        assert!(!opts.no_cache);
184        assert!(opts.timeout.is_none());
185        assert!(!opts.watch);
186    }
187}