1use 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#[derive(Debug, Clone)]
17pub struct Plan {
18 pub graph: PipelineGraph,
19 pub ir_json: String,
20 pub summary: PlanSummary,
21}
22
23impl Plan {
24 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
45fn 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#[derive(Debug, Clone)]
62pub struct SourceMeta {
63 pub branch: String,
64 pub commit: String,
65 pub message: Option<String>,
66 pub repo_name: Option<String>,
70}
71
72#[derive(Debug, Clone, Default)]
74pub struct RunOptions {
75 pub no_cache: bool,
76 pub timeout: Option<Duration>,
77 pub watch: bool,
79 pub keep_going: bool,
83}
84
85#[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 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 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); assert_eq!(plan.summary.step_count, 0); }
123
124 #[test]
125 fn plan_summary_matches_scheduler_for_single_chain() {
126 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 assert_eq!(plan.ir_json, json);
148 }
149
150 #[test]
151 fn plan_summary_counts_two_independent_chains() {
152 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}