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}
67
68#[derive(Debug, Clone, Default)]
70pub struct RunOptions {
71 pub no_cache: bool,
72 pub timeout: Option<Duration>,
73 pub watch: bool,
75 pub keep_going: bool,
79}
80
81#[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 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); assert_eq!(plan.summary.step_count, 0); }
112
113 #[test]
114 fn plan_summary_matches_scheduler_for_single_chain() {
115 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 assert_eq!(plan.ir_json, json);
137 }
138
139 #[test]
140 fn plan_summary_counts_two_independent_chains() {
141 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}