1use super::{PipelineReport, PipelineStatus, TaskStatus};
2use std::fmt::Write;
3
4#[must_use]
8pub fn generate_summary(report: &PipelineReport) -> String {
9 let mut md = String::new();
10
11 let (status_emoji, status_text) = match report.status {
13 PipelineStatus::Success => ("\u{2705}", "Success"), PipelineStatus::Failed => ("\u{274C}", "Failed"), PipelineStatus::Partial => ("\u{26A0}\u{FE0F}", "Partial"), PipelineStatus::Pending => ("\u{23F3}", "Pending"), };
18
19 let _ = writeln!(md, "## {status_emoji} cuenv CI Report - {status_text}\n");
20
21 let duration = report
23 .duration_ms
24 .map_or_else(|| "—".to_string(), format_duration);
25
26 md.push_str("| Project | Pipeline | Status | Duration |\n");
27 md.push_str("|:--------|:---------|:------:|:--------:|\n");
28 let _ = writeln!(
29 md,
30 "| `{}` | `{}` | {status_emoji} {status_text} | {duration} |\n",
31 report.project, report.pipeline
32 );
33
34 if !report.tasks.is_empty() {
36 md.push_str("### Tasks\n\n");
37 md.push_str("| Task | Status | Duration |\n");
38 md.push_str("|:-----|:------:|:--------:|\n");
39
40 for task in &report.tasks {
41 let (task_emoji, task_status) = match task.status {
42 TaskStatus::Success => ("\u{2705}", "Passed"), TaskStatus::Failed => ("\u{274C}", "Failed"), TaskStatus::Cached => ("\u{1F4BE}", "Cached"), TaskStatus::Skipped => ("\u{23ED}\u{FE0F}", "Skipped"), };
47 let task_duration = format_duration(task.duration_ms);
48 let _ = writeln!(
49 md,
50 "| `{}` | {task_emoji} {task_status} | {task_duration} |",
51 task.name
52 );
53 }
54 md.push('\n');
55 }
56
57 md.push_str("### Details\n\n");
59 md.push_str("| Property | Value |\n|:---------|:------|\n");
60 let _ = writeln!(
61 md,
62 "| Commit | `{}` |",
63 &report.context.sha[..8.min(report.context.sha.len())]
64 );
65 let _ = writeln!(md, "| Ref | `{}` |", report.context.ref_name);
66 if let Some(base_ref) = &report.context.base_ref {
67 let _ = writeln!(md, "| Base | `{base_ref}` |");
68 }
69 let _ = writeln!(
70 md,
71 "| Changed files | {} |",
72 report.context.changed_files.len()
73 );
74 let _ = writeln!(md, "| Provider | {} |", report.context.provider);
75
76 let _ = write!(md, "\n---\n*cuenv v{}*\n", report.version);
78
79 md
80}
81
82const JOB_SUMMARY_ENV_VARS: &[&str] = &[
89 "GITHUB_STEP_SUMMARY", ];
92
93pub fn write_job_summary(report: &PipelineReport) -> std::io::Result<()> {
105 use std::io::Write as IoWrite;
106
107 for env_var in JOB_SUMMARY_ENV_VARS {
109 if let Ok(path) = std::env::var(env_var) {
110 let summary = generate_summary(report);
111 let mut file = std::fs::OpenOptions::new()
112 .create(true)
113 .append(true)
114 .open(&path)?;
115 writeln!(file, "{summary}")?;
116 tracing::info!("Wrote job summary to {path} (via {env_var})");
117 return Ok(());
118 }
119 }
120
121 tracing::debug!(
123 "No job summary mechanism available (checked: {:?})",
124 JOB_SUMMARY_ENV_VARS
125 );
126 Ok(())
127}
128
129#[allow(clippy::cast_precision_loss)]
131fn format_duration(ms: u64) -> String {
132 if ms < 1000 {
133 format!("{ms}ms")
134 } else if ms < 60_000 {
135 format!("{:.1}s", ms as f64 / 1000.0)
136 } else {
137 let minutes = ms / 60_000;
138 let seconds = (ms % 60_000) / 1000;
139 format!("{minutes}m {seconds}s")
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use crate::report::{ContextReport, TaskReport};
147 use chrono::Utc;
148
149 #[test]
150 fn test_generate_summary_success() {
151 let report = PipelineReport {
152 version: "0.11.8".to_string(),
153 project: "my-project".to_string(),
154 pipeline: "ci".to_string(),
155 context: ContextReport {
156 provider: "github".to_string(),
157 event: "pull_request".to_string(),
158 ref_name: "refs/pull/123/merge".to_string(),
159 base_ref: Some("main".to_string()),
160 sha: "abc123def456".to_string(),
161 changed_files: vec!["src/lib.rs".to_string()],
162 },
163 started_at: Utc::now(),
164 completed_at: Some(Utc::now()),
165 duration_ms: Some(5432),
166 status: PipelineStatus::Success,
167 tasks: vec![TaskReport {
168 name: "check".to_string(),
169 status: TaskStatus::Success,
170 duration_ms: 5000,
171 exit_code: Some(0),
172 inputs_matched: vec![],
173 cache_key: None,
174 outputs: vec![],
175 }],
176 };
177
178 let md = generate_summary(&report);
179 assert!(md.contains("\u{2705}")); assert!(md.contains("my-project"));
181 assert!(md.contains("check"));
182 assert!(md.contains("abc123de"));
183 assert!(md.contains("Success"));
184 }
185
186 #[test]
187 fn test_generate_summary_failed() {
188 let report = PipelineReport {
189 version: "0.11.8".to_string(),
190 project: "my-project".to_string(),
191 pipeline: "ci".to_string(),
192 context: ContextReport {
193 provider: "github".to_string(),
194 event: "pull_request".to_string(),
195 ref_name: "refs/pull/123/merge".to_string(),
196 base_ref: Some("main".to_string()),
197 sha: "abc123def456".to_string(),
198 changed_files: vec!["src/lib.rs".to_string()],
199 },
200 started_at: Utc::now(),
201 completed_at: Some(Utc::now()),
202 duration_ms: Some(5432),
203 status: PipelineStatus::Failed,
204 tasks: vec![TaskReport {
205 name: "check".to_string(),
206 status: TaskStatus::Failed,
207 duration_ms: 5000,
208 exit_code: Some(1),
209 inputs_matched: vec![],
210 cache_key: None,
211 outputs: vec![],
212 }],
213 };
214
215 let md = generate_summary(&report);
216 assert!(md.contains("\u{274C}")); assert!(md.contains("Failed"));
218 }
219
220 #[test]
221 fn test_format_duration() {
222 assert_eq!(format_duration(500), "500ms");
223 assert_eq!(format_duration(1500), "1.5s");
224 assert_eq!(format_duration(65000), "1m 5s");
225 }
226}