1use serde::{Deserialize, Serialize};
16
17use super::{InternalVerb, VerbCategory};
18
19pub struct CiPipelineRun;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
27pub struct CiPipelineRunInput {
28 pub pipeline: String,
32 #[serde(rename = "ref")]
34 pub git_ref: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub variables: Option<std::collections::BTreeMap<String, String>>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
44pub struct CiPipelineRunOutput {
45 pub run_id: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub url: Option<String>,
52}
53
54impl InternalVerb for CiPipelineRun {
55 type Input = CiPipelineRunInput;
56 type Output = CiPipelineRunOutput;
57 const ID: &'static str = "ci.pipeline.run";
58 const CATEGORY: VerbCategory = VerbCategory::Ci;
59}
60
61pub struct CiPipelineStatus;
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
69pub struct CiPipelineStatusInput {
70 pub run_id: String,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
76#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
77#[serde(rename_all = "snake_case")]
78pub enum PipelinePhase {
79 Pending,
81 Running,
83 Passing,
85 Failing,
87 Cancelled,
89 Unknown,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
96pub struct CiPipelineStatusOutput {
97 pub phase: PipelinePhase,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub url: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub detail: Option<String>,
105}
106
107impl InternalVerb for CiPipelineStatus {
108 type Input = CiPipelineStatusInput;
109 type Output = CiPipelineStatusOutput;
110 const ID: &'static str = "ci.pipeline.status";
111 const CATEGORY: VerbCategory = VerbCategory::Ci;
112}
113
114pub struct CiArtifactFetch;
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
122pub struct CiArtifactFetchInput {
123 pub run_id: String,
125 pub artifact_name: String,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
133pub struct CiArtifactFetchOutput {
134 pub download_url: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub expires_at: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub size_bytes: Option<u64>,
144}
145
146impl InternalVerb for CiArtifactFetch {
147 type Input = CiArtifactFetchInput;
148 type Output = CiArtifactFetchOutput;
149 const ID: &'static str = "ci.artifact.fetch";
150 const CATEGORY: VerbCategory = VerbCategory::Ci;
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn verb_ids_match_canonical_namespace() {
159 for id in [CiPipelineRun::ID, CiPipelineStatus::ID, CiArtifactFetch::ID] {
160 assert!(id.starts_with("ci."), "{id}");
161 }
162 }
163
164 #[test]
165 fn verbs_are_under_ci_category() {
166 assert_eq!(CiPipelineRun::CATEGORY, VerbCategory::Ci);
167 assert_eq!(CiPipelineStatus::CATEGORY, VerbCategory::Ci);
168 assert_eq!(CiArtifactFetch::CATEGORY, VerbCategory::Ci);
169 }
170
171 #[test]
172 fn pipeline_run_input_ref_renamed_in_wire() {
173 let wire = r#"{"pipeline":"release.yml","ref":"main"}"#;
174 let parsed: CiPipelineRunInput = serde_json::from_str(wire).unwrap();
175 assert_eq!(parsed.git_ref, "main");
176 let back = serde_json::to_value(&parsed).unwrap();
178 assert!(back.get("ref").is_some(), "must serialize as 'ref'");
179 assert!(back.get("git_ref").is_none());
180 }
181
182 #[test]
183 fn pipeline_run_variables_optional() {
184 let no_vars = r#"{"pipeline":"build.yml","ref":"main"}"#;
185 let parsed: CiPipelineRunInput = serde_json::from_str(no_vars).unwrap();
186 assert!(parsed.variables.is_none());
187
188 let with_vars = r#"{"pipeline":"build.yml","ref":"main","variables":{"ENV":"staging"}}"#;
189 let parsed: CiPipelineRunInput = serde_json::from_str(with_vars).unwrap();
190 assert_eq!(
191 parsed
192 .variables
193 .as_ref()
194 .unwrap()
195 .get("ENV")
196 .map(|s| s.as_str()),
197 Some("staging")
198 );
199 }
200
201 #[test]
202 fn pipeline_phase_is_snake_case() {
203 assert_eq!(
204 serde_json::to_string(&PipelinePhase::Passing).unwrap(),
205 "\"passing\""
206 );
207 assert_eq!(
208 serde_json::to_string(&PipelinePhase::Unknown).unwrap(),
209 "\"unknown\""
210 );
211 }
212
213 #[test]
214 fn pipeline_status_omits_detail_when_absent() {
215 let out = CiPipelineStatusOutput {
216 phase: PipelinePhase::Running,
217 url: None,
218 detail: None,
219 };
220 let wire = serde_json::to_value(&out).unwrap();
221 assert!(!wire.as_object().unwrap().contains_key("detail"));
222 }
223
224 #[test]
225 fn artifact_fetch_output_omits_optional_fields_when_absent() {
226 let out = CiArtifactFetchOutput {
227 download_url: "https://example.com/artifact.tar.gz".into(),
228 expires_at: None,
229 size_bytes: None,
230 };
231 let wire = serde_json::to_value(&out).unwrap();
232 assert!(!wire.as_object().unwrap().contains_key("expires_at"));
233 assert!(!wire.as_object().unwrap().contains_key("size_bytes"));
234 }
235
236 #[cfg(feature = "json-schema")]
237 #[test]
238 fn verbs_emit_schemas_via_for_verb() {
239 use super::super::VerbDescriptor;
240
241 let run = VerbDescriptor::for_verb::<CiPipelineRun>();
242 assert_eq!(run.id, "ci.pipeline.run");
243 assert!(run.input_schema.to_string().contains("pipeline"));
244
245 let status = VerbDescriptor::for_verb::<CiPipelineStatus>();
246 assert_eq!(status.id, "ci.pipeline.status");
247 assert!(status.output_schema.to_string().contains("phase"));
248
249 let fetch = VerbDescriptor::for_verb::<CiArtifactFetch>();
250 assert_eq!(fetch.id, "ci.artifact.fetch");
251 assert!(fetch.output_schema.to_string().contains("download_url"));
252 }
253}