Skip to main content

cuenv_ci/report/
markdown.rs

1use super::{PipelineReport, PipelineStatus, TaskStatus};
2use std::fmt::Write;
3
4/// Generate a markdown summary of the pipeline report.
5///
6/// This is used for PR comments, GitHub Check Run summaries, and Job Summaries.
7#[must_use]
8pub fn generate_summary(report: &PipelineReport) -> String {
9    let mut md = String::new();
10
11    // Header with status emoji
12    let (status_emoji, status_text) = match report.status {
13        PipelineStatus::Success => ("\u{2705}", "Success"), // ✅
14        PipelineStatus::Failed => ("\u{274C}", "Failed"),   // ❌
15        PipelineStatus::Partial => ("\u{26A0}\u{FE0F}", "Partial"), // ⚠️
16        PipelineStatus::Pending => ("\u{23F3}", "Pending"), // ⏳
17    };
18
19    let _ = writeln!(md, "## {status_emoji} cuenv CI Report - {status_text}\n");
20
21    // Summary table
22    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    // Tasks table (if any)
35    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"), // ✅
43                TaskStatus::Failed => ("\u{274C}", "Failed"),  // ❌
44                TaskStatus::Cached => ("\u{1F4BE}", "Cached"), // 💾
45                TaskStatus::Skipped => ("\u{23ED}\u{FE0F}", "Skipped"), // ⏭️
46            };
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    // Context details
58    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    // Footer
77    let _ = write!(md, "\n---\n*cuenv v{}*\n", report.version);
78
79    md
80}
81
82/// Known CI system environment variables for job summary output.
83///
84/// Each CI system has its own mechanism for displaying job summaries:
85/// - GitHub Actions: `GITHUB_STEP_SUMMARY` - append markdown to this file
86/// - GitLab CI: `CI_JOB_URL` - no native summary, but we could post to MR (future)
87/// - Buildkite: `BUILDKITE_ANNOTATION_CONTEXT` - use buildkite-agent annotate (future)
88const JOB_SUMMARY_ENV_VARS: &[&str] = &[
89    "GITHUB_STEP_SUMMARY", // GitHub Actions
90                           // Future: Add other CI systems as they're implemented
91];
92
93/// Write the summary to the CI system's job summary mechanism.
94///
95/// Uses runtime detection to find the appropriate summary file/mechanism.
96/// Currently supports:
97/// - GitHub Actions: writes to `$GITHUB_STEP_SUMMARY`
98///
99/// Appends to the file to support multiple projects in a single run.
100///
101/// # Errors
102///
103/// Returns an error if the file cannot be opened or written to.
104pub fn write_job_summary(report: &PipelineReport) -> std::io::Result<()> {
105    use std::io::Write as IoWrite;
106
107    // Try each known CI system's summary mechanism
108    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    // No summary mechanism available - this is not an error
122    tracing::debug!(
123        "No job summary mechanism available (checked: {:?})",
124        JOB_SUMMARY_ENV_VARS
125    );
126    Ok(())
127}
128
129/// Format duration in milliseconds to a human-readable string.
130#[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}")); // ✅
180        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}")); // ❌
217        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}