Skip to main content

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}