Skip to main content

hm_pipeline_ir/
graph.rs

1use std::collections::BTreeMap;
2use std::num::NonZeroU32;
3
4use daggy::Dag;
5
6use schemars::JsonSchema as DeriveJsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// A single build command within a pipeline.
10///
11/// Serialized as a JSON object inside each graph node's `step` field.
12/// The `key` is the unique identifier used to reference this step in
13/// edges and log output.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)]
15pub struct CommandStep {
16    /// Unique identifier for this step within the pipeline.
17    pub key: String,
18    /// Human-readable label shown in build output.
19    #[serde(default)]
20    pub label: Option<String>,
21    /// Shell command to execute inside the container.
22    pub cmd: String,
23    /// Docker image to boot from. Root steps without an image inherit
24    /// `PipelineGraph::default_image`; child steps boot from their
25    /// parent's committed snapshot.
26    #[serde(default)]
27    pub image: Option<String>,
28    /// Per-step environment variables merged on top of the pipeline env.
29    #[serde(default)]
30    pub env: Option<BTreeMap<String, String>>,
31    /// Maximum wall-clock seconds before the step is killed.
32    ///
33    /// `NonZeroU32`: a `0`-second budget is rejected at the wire boundary.
34    #[serde(default)]
35    pub timeout_seconds: Option<NonZeroU32>,
36    /// Cache configuration for this step's committed snapshot.
37    #[serde(default)]
38    pub cache: Option<Cache>,
39    /// Step-executor plugin name. `None` falls back to the default
40    /// runner (Docker in the shipped configuration).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub runner: Option<String>,
43    /// Plugin-specific extra fields passed verbatim to the runner.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub runner_args: Option<serde_json::Value>,
46}
47
48/// Snapshot cache configuration for a step.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)]
50pub struct Cache {
51    /// Cache policy name (e.g. `"content-hash"`).
52    pub policy: String,
53    /// Explicit cache key override; derived from the step if absent.
54    #[serde(default)]
55    pub key: Option<String>,
56}
57
58/// A graph node: a [`CommandStep`] paired with its resolved environment.
59///
60/// The `env` map is the final merged result of pipeline-level defaults
61/// and per-step overrides — ready to hand to the executor as-is.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Transition {
64    pub step: CommandStep,
65    pub env: BTreeMap<String, String>,
66}
67
68/// Edge label in the pipeline DAG.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum EdgeKind {
72    /// Container lineage: the child boots from the parent's committed
73    /// snapshot rather than from a fresh image.
74    BuildsIn,
75    /// Ordering-only dependency (emitted by `wait` barriers). The
76    /// child waits for the parent to finish but does not inherit its
77    /// snapshot.
78    DependsOn,
79}
80
81/// Top-level pipeline graph, deserialized directly from the v0 wire
82/// format (petgraph-serde JSON).
83///
84/// Callers access the underlying [`Dag`] via [`dag()`](Self::dag) and
85/// traverse it with petgraph's standard visitor traits.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PipelineGraph {
88    #[serde(default = "default_version")]
89    version: String,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    default_image: Option<String>,
92    /// Whole-build wall-clock budget in seconds. When set, the local
93    /// orchestrator kills the run and fails it once this elapses.
94    ///
95    /// `NonZeroU32` makes a `0`-second budget (kill immediately) an
96    /// unrepresentable, wire-rejected value rather than a runtime footgun.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    timeout_seconds: Option<NonZeroU32>,
99    #[serde(rename = "graph")]
100    inner: Dag<Transition, EdgeKind>,
101}
102
103fn default_version() -> String {
104    "0".to_string()
105}
106
107impl PipelineGraph {
108    /// Number of steps (nodes) in the graph.
109    #[must_use]
110    pub fn node_count(&self) -> usize {
111        self.inner.node_count()
112    }
113
114    /// Pipeline-wide fallback image for root steps that don't declare one.
115    #[must_use]
116    pub fn default_image(&self) -> Option<&str> {
117        self.default_image.as_deref()
118    }
119
120    /// Whole-build wall-clock budget in seconds, if the author set one.
121    ///
122    /// The returned value is positive by construction (`0` is rejected at
123    /// the wire boundary), so consumers need no `> 0` guard.
124    #[must_use]
125    pub const fn timeout_seconds(&self) -> Option<NonZeroU32> {
126        self.timeout_seconds
127    }
128
129    /// The underlying DAG for direct traversal.
130    #[must_use]
131    pub const fn dag(&self) -> &Dag<Transition, EdgeKind> {
132        &self.inner
133    }
134}
135
136#[cfg(test)]
137mod timeout_tests {
138    #![allow(clippy::unwrap_used, clippy::expect_used)]
139
140    use std::num::NonZeroU32;
141
142    use super::PipelineGraph;
143
144    #[test]
145    fn deserializes_pipeline_timeout_seconds() {
146        let json = r#"{
147            "version": "0",
148            "timeout_seconds": 1800,
149            "graph": {"nodes": [], "node_holes": [], "edge_property": "directed", "edges": []}
150        }"#;
151        let g: PipelineGraph = serde_json::from_str(json).unwrap();
152        assert_eq!(g.timeout_seconds(), NonZeroU32::new(1800));
153    }
154
155    #[test]
156    fn rejects_zero_pipeline_timeout_seconds() {
157        let json = r#"{
158            "version": "0",
159            "timeout_seconds": 0,
160            "graph": {"nodes": [], "node_holes": [], "edge_property": "directed", "edges": []}
161        }"#;
162        assert!(serde_json::from_str::<PipelineGraph>(json).is_err());
163    }
164
165    #[test]
166    fn pipeline_timeout_defaults_to_none() {
167        let json = r#"{
168            "version": "0",
169            "graph": {"nodes": [], "node_holes": [], "edge_property": "directed", "edges": []}
170        }"#;
171        let g: PipelineGraph = serde_json::from_str(json).unwrap();
172        assert_eq!(g.timeout_seconds(), None);
173    }
174}