use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use hm_pipeline_ir::PipelineGraph;
use hm_plugin_protocol::events::PlanSummary;
#[derive(Debug, Clone)]
pub struct Plan {
pub graph: PipelineGraph,
pub ir_json: String,
pub summary: PlanSummary,
}
impl Plan {
pub fn parse(ir_json: String) -> crate::Result<Self> {
let graph: PipelineGraph = serde_json::from_slice(ir_json.as_bytes()).map_err(|e| {
crate::BackendError::Rejected {
code: "invalid_ir".into(),
message: format!("could not parse pipeline IR: {e}"),
}
})?;
let summary = summarize(&graph);
Ok(Self {
graph,
ir_json,
summary,
})
}
}
fn summarize(graph: &PipelineGraph) -> PlanSummary {
PlanSummary {
step_count: graph.node_count(),
chain_count: crate::local::chain_count(graph.dag()),
default_runner: "docker".to_string(),
}
}
#[derive(Debug, Clone)]
pub struct SourceMeta {
pub branch: String,
pub commit: String,
pub message: Option<String>,
pub repo_name: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RunOptions {
pub no_cache: bool,
pub timeout: Option<Duration>,
pub watch: bool,
pub keep_going: bool,
}
#[derive(Debug, Clone)]
pub struct RunRequest {
pub plan: Plan,
pub repo_root: PathBuf,
pub pipeline_slug: String,
pub env: BTreeMap<String, String>,
pub source: SourceMeta,
pub options: RunOptions,
pub cloud_pipeline_slug: Option<String>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const EMPTY_IR: &str = r#"{"version":"0","graph":{"nodes":[],"node_holes":[],"edge_property":"directed","edges":[]}}"#;
#[test]
fn plan_keeps_verbatim_json_and_typed_graph() {
let json = EMPTY_IR.to_string();
let plan = Plan::parse(json.clone()).expect("parse");
assert_eq!(plan.ir_json, json); assert_eq!(plan.summary.step_count, 0); }
#[test]
fn plan_summary_matches_scheduler_for_single_chain() {
let json = r#"{
"version": "0",
"default_image": "ubuntu:24.04",
"graph": {
"nodes": [
{"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}},
{"step": {"key": "b", "cmd": "echo b"}, "env": {}}
],
"node_holes": [],
"edge_property": "directed",
"edges": [[0, 1, "builds_in"]]
}
}"#
.to_string();
let plan = Plan::parse(json.clone()).expect("parse");
assert_eq!(plan.summary.step_count, 2);
assert_eq!(plan.summary.chain_count, 1);
assert_eq!(plan.summary.default_runner, "docker");
assert_eq!(plan.ir_json, json);
}
#[test]
fn plan_summary_counts_two_independent_chains() {
let json = r#"{
"version": "0",
"graph": {
"nodes": [
{"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}},
{"step": {"key": "b", "cmd": "echo b", "image": "ubuntu:24.04"}, "env": {}}
],
"node_holes": [],
"edge_property": "directed",
"edges": []
}
}"#
.to_string();
let plan = Plan::parse(json).expect("parse");
assert_eq!(plan.summary.step_count, 2);
assert_eq!(plan.summary.chain_count, 2);
}
#[test]
fn invalid_ir_returns_rejected_error() {
let err = Plan::parse("not json at all".to_string()).unwrap_err();
assert!(matches!(err, crate::BackendError::Rejected { .. }));
let msg = err.to_string();
assert!(msg.contains("invalid_ir"));
}
#[test]
fn run_options_default_is_zero() {
let opts = RunOptions::default();
assert!(!opts.no_cache);
assert!(opts.timeout.is_none());
assert!(!opts.watch);
}
}