1use std::collections::HashMap;
31
32use crate::transform::render_exprstring;
33use yah_qed_gha::{classify_workflow, Disposition, NativeReplacement, Workflow};
34
35#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WorkflowPorts {
39 pub reusable: bool,
44 pub inputs: Vec<PortInput>,
46 pub secrets: Vec<PortSecret>,
48 pub outputs: Vec<PortOutput>,
51 pub tier3_assumptions: Vec<NativeReplacement>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PortInput {
59 pub name: String,
60 pub required: bool,
61 pub ty: Option<String>,
63 pub default: Option<String>,
65 pub description: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct PortSecret {
73 pub name: String,
74 pub required: bool,
75 pub env_var: String,
77 pub description: Option<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PortOutput {
84 pub name: String,
85 pub artifact: String,
88 pub value: Option<String>,
91 pub description: Option<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum PortError {
97 MissingRequired(Vec<String>),
99}
100
101impl WorkflowPorts {
102 pub fn resolve_inputs(
106 &self,
107 supplied: &HashMap<String, String>,
108 ) -> Result<HashMap<String, String>, PortError> {
109 let mut resolved = HashMap::new();
110 let mut missing = Vec::new();
111 for input in &self.inputs {
112 if let Some(v) = supplied.get(&input.name) {
113 resolved.insert(input.name.clone(), v.clone());
114 } else if let Some(d) = &input.default {
115 resolved.insert(input.name.clone(), d.clone());
116 } else if input.required {
117 missing.push(input.name.clone());
118 }
119 }
120 if missing.is_empty() {
121 Ok(resolved)
122 } else {
123 Err(PortError::MissingRequired(missing))
124 }
125 }
126
127 pub fn output_artifacts(&self) -> impl Iterator<Item = &PortOutput> {
129 self.outputs.iter()
130 }
131
132 pub fn secret_env(&self) -> HashMap<String, String> {
134 self.secrets.iter().map(|s| (s.env_var.clone(), s.name.clone())).collect()
135 }
136
137 pub fn assumes(&self, facility: NativeReplacement) -> bool {
140 self.tier3_assumptions.contains(&facility)
141 }
142}
143
144pub fn workflow_ports(wf: &Workflow) -> WorkflowPorts {
151 let call = wf.triggers.workflow_call.as_ref();
152 let reusable = call.is_some();
153
154 let inputs = call
155 .map(|c| {
156 c.inputs
157 .iter()
158 .map(|(name, i)| PortInput {
159 name: name.clone(),
160 required: i.required.unwrap_or(false),
161 ty: i.r#type.clone(),
162 default: i.default.as_ref().map(render_exprstring),
163 description: i.description.clone(),
164 })
165 .collect()
166 })
167 .unwrap_or_default();
168
169 let secrets = call
170 .map(|c| {
171 c.secrets
172 .iter()
173 .map(|(name, s)| PortSecret {
174 name: name.clone(),
175 required: s.required.unwrap_or(false),
176 env_var: name.clone(),
177 description: s.description.clone(),
178 })
179 .collect()
180 })
181 .unwrap_or_default();
182
183 let outputs = call
184 .map(|c| {
185 c.outputs
186 .iter()
187 .map(|(name, o)| PortOutput {
188 name: name.clone(),
189 artifact: name.clone(),
190 value: o.value.as_ref().map(render_exprstring),
191 description: o.description.clone(),
192 })
193 .collect()
194 })
195 .unwrap_or_default();
196
197 WorkflowPorts { reusable, inputs, secrets, outputs, tier3_assumptions: tier3_assumptions(wf) }
198}
199
200fn tier3_assumptions(wf: &Workflow) -> Vec<NativeReplacement> {
204 let mut out: Vec<NativeReplacement> = Vec::new();
205 for classified in classify_workflow(wf) {
206 if let Disposition::ReplaceWithNative(nr) = classified.class.disposition {
207 if !out.contains(&nr) {
208 out.push(nr);
209 }
210 }
211 }
212 out
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn wf(src: &str) -> Workflow {
220 yah_qed_gha::parse_workflow(src).expect("parse")
221 }
222
223 const REUSABLE: &str = r#"
224name: build-and-publish
225on:
226 workflow_call:
227 inputs:
228 tag:
229 required: true
230 type: string
231 channel:
232 required: false
233 type: string
234 default: stable
235 secrets:
236 CARGO_TOKEN:
237 required: true
238 outputs:
239 digest:
240 description: the built image digest
241 value: ${{ jobs.build.outputs.digest }}
242jobs:
243 build:
244 runs-on: ubuntu-latest
245 steps:
246 - uses: actions/checkout@v4
247 - uses: actions/upload-artifact@v4
248 - run: cargo build --release
249"#;
250
251 #[test]
252 fn reusable_workflow_declares_typed_ports() {
253 let p = workflow_ports(&wf(REUSABLE));
254 assert!(p.reusable);
255
256 assert_eq!(p.inputs.len(), 2);
258 let tag = &p.inputs[0];
259 assert_eq!(tag.name, "tag");
260 assert!(tag.required);
261 assert_eq!(tag.ty.as_deref(), Some("string"));
262 let channel = &p.inputs[1];
263 assert!(!channel.required);
264 assert_eq!(channel.default.as_deref(), Some("stable"));
265
266 assert_eq!(p.secrets.len(), 1);
268 assert_eq!(p.secrets[0].name, "CARGO_TOKEN");
269 assert_eq!(p.secrets[0].env_var, "CARGO_TOKEN");
270 assert!(p.secrets[0].required);
271
272 assert_eq!(p.outputs.len(), 1);
274 assert_eq!(p.outputs[0].name, "digest");
275 assert_eq!(p.outputs[0].artifact, "digest");
276 assert_eq!(p.outputs[0].value.as_deref(), Some("${{ jobs.build.outputs.digest }}"));
277 }
278
279 #[test]
280 fn down_port_resolves_inputs_with_defaults_and_required() {
281 let p = workflow_ports(&wf(REUSABLE));
282
283 let err = p.resolve_inputs(&HashMap::new()).unwrap_err();
285 assert_eq!(err, PortError::MissingRequired(vec!["tag".into()]));
286
287 let supplied = HashMap::from([("tag".to_string(), "v1.2.3".to_string())]);
289 let resolved = p.resolve_inputs(&supplied).expect("resolves");
290 assert_eq!(resolved.get("tag").map(String::as_str), Some("v1.2.3"));
291 assert_eq!(resolved.get("channel").map(String::as_str), Some("stable"));
292 }
293
294 #[test]
295 fn secret_env_injection_map() {
296 let p = workflow_ports(&wf(REUSABLE));
297 let env = p.secret_env();
298 assert_eq!(env.get("CARGO_TOKEN").map(String::as_str), Some("CARGO_TOKEN"));
299 }
300
301 #[test]
302 fn explicit_tier3_boundary_declaration() {
303 let p = workflow_ports(&wf(REUSABLE));
304 assert!(p.assumes(NativeReplacement::Checkout));
306 assert!(p.assumes(NativeReplacement::UploadArtifact));
307 assert!(!p.assumes(NativeReplacement::ReleasePublisher));
308 assert_eq!(
310 p.tier3_assumptions,
311 vec![NativeReplacement::Checkout, NativeReplacement::UploadArtifact]
312 );
313 }
314
315 #[test]
316 fn non_reusable_workflow_has_no_ports_but_keeps_tier3_declaration() {
317 let src = r#"
318on: push
319jobs:
320 a:
321 runs-on: x
322 steps:
323 - uses: actions/checkout@v4
324 - run: make
325"#;
326 let p = workflow_ports(&wf(src));
327 assert!(!p.reusable);
328 assert!(p.inputs.is_empty());
329 assert!(p.secrets.is_empty());
330 assert!(p.outputs.is_empty());
331 assert_eq!(p.tier3_assumptions, vec![NativeReplacement::Checkout]);
333 }
334
335 #[test]
336 fn tier3_assumptions_dedupe_across_jobs() {
337 let src = r#"
339on: workflow_call
340jobs:
341 a:
342 runs-on: x
343 steps:
344 - uses: actions/checkout@v4
345 b:
346 runs-on: x
347 steps:
348 - uses: actions/checkout@v4
349 - uses: actions/cache@v4
350"#;
351 let p = workflow_ports(&wf(src));
352 assert_eq!(
353 p.tier3_assumptions,
354 vec![NativeReplacement::Checkout, NativeReplacement::ContentAddressedCache]
355 );
356 }
357
358 #[test]
359 fn output_artifacts_iterator_exposes_up_port() {
360 let p = workflow_ports(&wf(REUSABLE));
361 let arts: Vec<&str> = p.output_artifacts().map(|o| o.artifact.as_str()).collect();
362 assert_eq!(arts, vec!["digest"]);
363 }
364}