cuenv_ci/emitter/mod.rs
1//! CI Pipeline Emitter Trait
2//!
3//! Defines the interface for emitting CI configurations from the intermediate
4//! representation (IR). Implementations of this trait generate orchestrator-native
5//! configurations (e.g., Buildkite, GitLab CI, Tekton).
6//!
7//! ## Pipeline Modes
8//!
9//! Emitters support two pipeline generation modes:
10//!
11//! - **Thin mode**: Generates a single-job workflow that delegates orchestration to cuenv.
12//! Bootstrap → `cuenv ci --pipeline <name>` → Finalizers
13//!
14//! - **Expanded mode**: Generates multi-job workflows with each task as a separate job,
15//! with dependencies managed by the CI orchestrator.
16//!
17//! ## Emitter Registry
18//!
19//! The [`EmitterRegistry`] provides a central registry for all available emitters,
20//! enabling dynamic lookup and discovery of available formats.
21
22mod registry;
23
24pub use registry::{EmitterInfo, EmitterRegistry, EmitterRegistryBuilder};
25
26use crate::ir::IntermediateRepresentation;
27use cuenv_core::ci::PipelineMode;
28use thiserror::Error;
29
30/// Error types for emitter operations
31#[derive(Debug, Error)]
32pub enum EmitterError {
33 /// YAML/JSON serialization failed
34 #[error("Serialization failed: {0}")]
35 Serialization(String),
36
37 /// Invalid IR structure for this emitter
38 #[error("Invalid IR: {0}")]
39 InvalidIR(String),
40
41 /// Unsupported feature in IR for this emitter
42 #[error("Unsupported feature '{feature}' for {emitter} emitter")]
43 UnsupportedFeature {
44 feature: String,
45 emitter: &'static str,
46 },
47
48 /// IO error during emission
49 #[error("IO error: {0}")]
50 Io(#[from] std::io::Error),
51}
52
53/// Result type for emitter operations
54pub type EmitterResult<T> = std::result::Result<T, EmitterError>;
55
56/// Trait for CI configuration emitters
57///
58/// Implementations transform the IR into orchestrator-specific configurations.
59/// Each emitter is responsible for mapping IR concepts to the target format.
60///
61/// ## Pipeline Modes
62///
63/// Emitters must implement both `emit_thin` and `emit_expanded` methods:
64///
65/// - `emit_thin`: Single-job workflow with cuenv orchestration
66/// - `emit_expanded`: Multi-job workflow with orchestrator dependencies
67///
68/// The default `emit` method dispatches based on `ir.pipeline.mode`.
69///
70/// # Example
71///
72/// ```ignore
73/// use cuenv_ci::emitter::{Emitter, EmitterResult};
74/// use cuenv_ci::ir::IntermediateRepresentation;
75///
76/// struct MyEmitter;
77///
78/// impl Emitter for MyEmitter {
79/// fn emit_thin(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
80/// // Generate single-job workflow
81/// Ok("# Thin mode config".to_string())
82/// }
83///
84/// fn emit_expanded(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
85/// // Generate multi-job workflow
86/// Ok("# Expanded mode config".to_string())
87/// }
88///
89/// fn format_name(&self) -> &'static str {
90/// "my-ci"
91/// }
92///
93/// fn file_extension(&self) -> &'static str {
94/// "yml"
95/// }
96/// }
97/// ```
98pub trait Emitter: Send + Sync {
99 /// Emit a thin mode CI configuration.
100 ///
101 /// Thin mode generates a single-job workflow that:
102 /// 1. Runs bootstrap phase steps (e.g., install Nix)
103 /// 2. Runs setup phase steps (e.g., build cuenv)
104 /// 3. Executes `cuenv ci --pipeline <name>` for orchestration
105 /// 4. Runs success/failure phase steps with conditions
106 ///
107 /// # Arguments
108 /// * `ir` - The compiled intermediate representation
109 ///
110 /// # Returns
111 /// The generated CI configuration as a string
112 ///
113 /// # Errors
114 /// Returns `EmitterError` if the IR cannot be transformed or serialized
115 fn emit_thin(&self, ir: &IntermediateRepresentation) -> EmitterResult<String>;
116
117 /// Emit an expanded mode CI configuration.
118 ///
119 /// Expanded mode generates a multi-job workflow where:
120 /// - Each task becomes a separate job
121 /// - Task dependencies map to job dependencies (`needs:` in GitHub Actions)
122 /// - Phase tasks are included as steps within each job
123 ///
124 /// # Arguments
125 /// * `ir` - The compiled intermediate representation
126 ///
127 /// # Returns
128 /// The generated CI configuration as a string
129 ///
130 /// # Errors
131 /// Returns `EmitterError` if the IR cannot be transformed or serialized
132 fn emit_expanded(&self, ir: &IntermediateRepresentation) -> EmitterResult<String>;
133
134 /// Emit a CI configuration based on the mode in the IR.
135 ///
136 /// This is the primary entry point for emission. It dispatches to
137 /// `emit_thin` or `emit_expanded` based on `ir.pipeline.mode`.
138 ///
139 /// # Arguments
140 /// * `ir` - The compiled intermediate representation
141 ///
142 /// # Returns
143 /// The generated CI configuration as a string
144 ///
145 /// # Errors
146 /// Returns `EmitterError` if the IR cannot be transformed or serialized
147 fn emit(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
148 match ir.pipeline.mode {
149 PipelineMode::Thin => self.emit_thin(ir),
150 PipelineMode::Expanded => self.emit_expanded(ir),
151 }
152 }
153
154 /// Get the format identifier for this emitter
155 ///
156 /// Used for CLI flag matching (e.g., "buildkite", "gitlab", "tekton")
157 fn format_name(&self) -> &'static str;
158
159 /// Get the file extension for output files
160 ///
161 /// Typically "yml" or "yaml" for most CI systems
162 fn file_extension(&self) -> &'static str;
163
164 /// Get a human-readable description of this emitter
165 fn description(&self) -> &'static str {
166 "CI configuration emitter"
167 }
168
169 /// Validate the IR before emission
170 ///
171 /// Override this to perform emitter-specific validation beyond
172 /// the standard IR validation.
173 ///
174 /// # Errors
175 /// Returns `EmitterError::InvalidIR` if validation fails
176 fn validate(&self, ir: &IntermediateRepresentation) -> EmitterResult<()> {
177 // Default: no additional validation
178 let _ = ir;
179 Ok(())
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::ir::{IntermediateRepresentation, PipelineMetadata};
187
188 struct TestEmitter;
189
190 impl Emitter for TestEmitter {
191 fn emit_thin(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
192 Ok(format!("# Thin Pipeline: {}", ir.pipeline.name))
193 }
194
195 fn emit_expanded(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
196 Ok(format!("# Expanded Pipeline: {}", ir.pipeline.name))
197 }
198
199 fn format_name(&self) -> &'static str {
200 "test"
201 }
202
203 fn file_extension(&self) -> &'static str {
204 "yml"
205 }
206 }
207
208 #[test]
209 fn test_emitter_trait_expanded_mode() {
210 let emitter = TestEmitter;
211 let ir = IntermediateRepresentation {
212 version: "1.5".to_string(),
213 pipeline: PipelineMetadata {
214 name: "my-pipeline".to_string(),
215 mode: PipelineMode::Expanded,
216 environment: None,
217 requires_onepassword: false,
218 project_name: None,
219 trigger: None,
220 pipeline_tasks: vec![],
221 pipeline_task_defs: vec![],
222 },
223 runtimes: vec![],
224 tasks: vec![],
225 };
226
227 // emit() dispatches to emit_expanded() for Expanded mode
228 let output = emitter.emit(&ir).unwrap();
229 assert_eq!(output, "# Expanded Pipeline: my-pipeline");
230 assert_eq!(emitter.format_name(), "test");
231 assert_eq!(emitter.file_extension(), "yml");
232 }
233
234 #[test]
235 fn test_emitter_trait_thin_mode() {
236 let emitter = TestEmitter;
237 let ir = IntermediateRepresentation {
238 version: "1.5".to_string(),
239 pipeline: PipelineMetadata {
240 name: "my-pipeline".to_string(),
241 mode: PipelineMode::Thin,
242 environment: None,
243 requires_onepassword: false,
244 project_name: None,
245 trigger: None,
246 pipeline_tasks: vec![],
247 pipeline_task_defs: vec![],
248 },
249 runtimes: vec![],
250 tasks: vec![],
251 };
252
253 // emit() dispatches to emit_thin() for Thin mode
254 let output = emitter.emit(&ir).unwrap();
255 assert_eq!(output, "# Thin Pipeline: my-pipeline");
256 }
257
258 #[test]
259 fn test_default_validation() {
260 let emitter = TestEmitter;
261 let ir = IntermediateRepresentation {
262 version: "1.5".to_string(),
263 pipeline: PipelineMetadata {
264 name: "test".to_string(),
265 mode: PipelineMode::default(),
266 environment: None,
267 requires_onepassword: false,
268 project_name: None,
269 trigger: None,
270 pipeline_tasks: vec![],
271 pipeline_task_defs: vec![],
272 },
273 runtimes: vec![],
274 tasks: vec![],
275 };
276
277 assert!(emitter.validate(&ir).is_ok());
278 }
279}