Skip to main content

trustformers_debug/
cicd_integration.rs

1//! CI/CD Integration tools for TrustformeRS debugging
2//!
3//! Provides interfaces for continuous integration and deployment workflows
4
5use anyhow::Result;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use uuid::Uuid;
11
12use crate::{DebugConfig, DebugReport, DebugSession};
13
14/// Errors from attempting to deliver a CI/CD notification.
15///
16/// Distinguishes *why* nothing was sent (never silently -- see
17/// `CICDIntegration::send_notifications`) from a genuine transport
18/// failure once delivery was actually attempted.
19#[derive(Debug, thiserror::Error)]
20pub enum NotificationError {
21    /// This crate was built without the `http-integrations` feature, so no
22    /// HTTP client is available to actually deliver the notification.
23    #[error(
24        "HTTP notification delivery is not enabled: rebuild trustformers-debug with \
25         `--features http-integrations`"
26    )]
27    HttpFeatureDisabled,
28    /// An `Email` channel has no HTTP email API endpoint configured (SMTP is
29    /// out of scope for this crate).
30    #[error("no HTTP email API endpoint configured for this Email notification channel")]
31    EmailNotConfigured,
32    /// This channel kind has no delivery implementation at all.
33    #[error("{0} notification channel has no delivery implementation")]
34    NotImplemented(&'static str),
35    /// The HTTP request was sent but failed (network error or non-success
36    /// status).
37    #[error("notification delivery failed: {0}")]
38    Transport(String),
39}
40
41/// POST a JSON payload to `url` with optional extra headers, real HTTP
42/// delivery. Only compiled when `http-integrations` is enabled (see
43/// `Cargo.toml`) -- this pulls in `reqwest` (and, transitively, a TLS stack
44/// that is not pure Rust), so it is intentionally kept out of the default
45/// build.
46#[cfg(feature = "http-integrations")]
47async fn post_json(
48    url: &str,
49    payload: &serde_json::Value,
50    extra_headers: &HashMap<String, String>,
51) -> Result<()> {
52    let client = reqwest::Client::new();
53    let mut request = client.post(url).json(payload);
54    for (key, value) in extra_headers {
55        request = request.header(key, value);
56    }
57
58    let response = request.send().await.map_err(|e| NotificationError::Transport(e.to_string()))?;
59
60    let status = response.status();
61    if !status.is_success() {
62        let body = response.text().await.unwrap_or_default();
63        return Err(NotificationError::Transport(format!("HTTP {status}: {body}")).into());
64    }
65    Ok(())
66}
67
68/// Without `http-integrations`, no HTTP client exists in this build: fail
69/// honestly instead of pretending to deliver.
70#[cfg(not(feature = "http-integrations"))]
71async fn post_json(
72    _url: &str,
73    _payload: &serde_json::Value,
74    _extra_headers: &HashMap<String, String>,
75) -> Result<()> {
76    Err(NotificationError::HttpFeatureDisabled.into())
77}
78
79/// CI/CD platform types
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub enum CICDPlatform {
82    GitHub,
83    GitLab,
84    Jenkins,
85    CircleCI,
86    AzureDevOps,
87    BitbucketPipelines,
88    TeamCity,
89    Travis,
90    Custom(String),
91}
92
93/// CI/CD integration configuration
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct CICDConfig {
96    pub platform: CICDPlatform,
97    pub project_id: String,
98    pub api_token: Option<String>,
99    pub base_url: Option<String>,
100    pub branch_filters: Vec<String>,
101    pub enable_regression_detection: bool,
102    pub enable_performance_tracking: bool,
103    pub enable_quality_gates: bool,
104    pub enable_automated_reports: bool,
105    pub enable_alert_systems: bool,
106    pub report_formats: Vec<ReportFormat>,
107    pub notification_channels: Vec<NotificationChannel>,
108}
109
110/// Report formats for CI/CD
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub enum ReportFormat {
113    JSON,
114    XML,
115    HTML,
116    Markdown,
117    JUnit,
118    SonarQube,
119    Custom(String),
120}
121
122/// Notification channels
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub enum NotificationChannel {
125    Email {
126        recipients: Vec<String>,
127        /// HTTP email API to POST to (e.g. a transactional-email provider's
128        /// REST endpoint). SMTP is explicitly out of scope. `None` means
129        /// "not configured": `send_email_notification` returns a
130        /// structured [`NotificationError::EmailNotConfigured`] rather than
131        /// silently doing nothing.
132        #[serde(default)]
133        api: Option<EmailApiConfig>,
134    },
135    Slack {
136        webhook_url: String,
137        channel: String,
138    },
139    Teams {
140        webhook_url: String,
141    },
142    Discord {
143        webhook_url: String,
144    },
145    Webhook {
146        url: String,
147        headers: HashMap<String, String>,
148    },
149    Custom(String),
150}
151
152/// HTTP email API configuration (transactional-email provider REST
153/// endpoint). See [`NotificationChannel::Email`].
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct EmailApiConfig {
156    /// Full URL of the provider's "send email" REST endpoint.
157    pub endpoint: String,
158    /// Extra headers to send with the request (e.g. `Authorization`).
159    #[serde(default)]
160    pub headers: HashMap<String, String>,
161}
162
163/// CI/CD pipeline stage
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub enum PipelineStage {
166    Build,
167    Test,
168    Debug,
169    Analysis,
170    Deploy,
171    Custom(String),
172}
173
174/// Quality gate status
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub enum QualityGateStatus {
177    Passed,
178    Failed,
179    Warning,
180    Skipped,
181}
182
183/// Quality gate configuration
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct QualityGate {
186    pub name: String,
187    pub description: String,
188    pub metric: QualityMetric,
189    pub threshold: f64,
190    pub operator: ComparisonOperator,
191    pub blocking: bool,
192}
193
194/// Quality metrics
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub enum QualityMetric {
197    TestCoverage,
198    ModelAccuracy,
199    TrainingLoss,
200    GradientNorm,
201    MemoryUsage,
202    TrainingTime,
203    ModelSize,
204    InferenceLatency,
205    Custom(String),
206}
207
208/// Comparison operators for quality gates
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub enum ComparisonOperator {
211    GreaterThan,
212    LessThan,
213    GreaterThanOrEqual,
214    LessThanOrEqual,
215    Equal,
216    NotEqual,
217}
218
219/// Regression detection result
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct RegressionResult {
222    pub detected: bool,
223    pub severity: RegressionSeverity,
224    pub metric: String,
225    pub baseline_value: f64,
226    pub current_value: f64,
227    pub change_percent: f64,
228    pub description: String,
229}
230
231/// Regression severity levels
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub enum RegressionSeverity {
234    Critical,
235    Major,
236    Minor,
237    Info,
238}
239
240/// Performance tracking data
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct PerformanceData {
243    pub timestamp: DateTime<Utc>,
244    pub commit_hash: String,
245    pub branch: String,
246    pub metrics: HashMap<String, f64>,
247    pub benchmark_results: Vec<BenchmarkResult>,
248}
249
250/// Benchmark result
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct BenchmarkResult {
253    pub name: String,
254    pub value: f64,
255    pub unit: String,
256    pub baseline: Option<f64>,
257    pub improvement_percent: Option<f64>,
258}
259
260/// CI/CD pipeline run result
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct PipelineResult {
263    pub run_id: Uuid,
264    pub timestamp: DateTime<Utc>,
265    pub commit_hash: String,
266    pub branch: String,
267    pub stage: PipelineStage,
268    pub status: PipelineStatus,
269    pub debug_report: Option<DebugReport>,
270    pub quality_gate_results: Vec<QualityGateResult>,
271    pub regression_results: Vec<RegressionResult>,
272    pub performance_data: Option<PerformanceData>,
273    pub artifacts: Vec<Artifact>,
274    pub duration_ms: u64,
275}
276
277/// Pipeline execution status
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub enum PipelineStatus {
280    Success,
281    Failed,
282    Warning,
283    Cancelled,
284    Timeout,
285}
286
287impl std::fmt::Display for PipelineStatus {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            PipelineStatus::Success => write!(f, "Success"),
291            PipelineStatus::Failed => write!(f, "Failed"),
292            PipelineStatus::Warning => write!(f, "Warning"),
293            PipelineStatus::Cancelled => write!(f, "Cancelled"),
294            PipelineStatus::Timeout => write!(f, "Timeout"),
295        }
296    }
297}
298
299/// Quality gate result
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct QualityGateResult {
302    pub gate: QualityGate,
303    pub status: QualityGateStatus,
304    pub actual_value: f64,
305    pub message: String,
306}
307
308/// Build artifact
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct Artifact {
311    pub name: String,
312    pub path: PathBuf,
313    pub size_bytes: u64,
314    pub checksum: String,
315    pub artifact_type: ArtifactType,
316}
317
318/// Artifact types
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub enum ArtifactType {
321    DebugReport,
322    TestResults,
323    BenchmarkResults,
324    Model,
325    Dataset,
326    Documentation,
327    Custom(String),
328}
329
330/// CI/CD integration manager
331#[derive(Debug)]
332pub struct CICDIntegration {
333    config: CICDConfig,
334    quality_gates: Vec<QualityGate>,
335    baseline_metrics: HashMap<String, f64>,
336    performance_history: Vec<PerformanceData>,
337    pipeline_history: Vec<PipelineResult>,
338}
339
340impl CICDIntegration {
341    /// Create a new CI/CD integration
342    pub fn new(config: CICDConfig) -> Self {
343        Self {
344            config,
345            quality_gates: Vec::new(),
346            baseline_metrics: HashMap::new(),
347            performance_history: Vec::new(),
348            pipeline_history: Vec::new(),
349        }
350    }
351
352    /// Add a quality gate
353    pub fn add_quality_gate(&mut self, gate: QualityGate) {
354        self.quality_gates.push(gate);
355    }
356
357    /// Set baseline metrics for regression detection
358    pub fn set_baseline_metrics(&mut self, metrics: HashMap<String, f64>) {
359        self.baseline_metrics = metrics;
360    }
361
362    /// Run debug analysis in CI/CD pipeline
363    pub async fn run_debug_analysis(
364        &mut self,
365        commit_hash: String,
366        branch: String,
367        debug_config: DebugConfig,
368    ) -> Result<PipelineResult> {
369        let run_id = Uuid::new_v4();
370        let start_time = Utc::now();
371
372        tracing::info!(
373            "Starting debug analysis for commit {} on branch {}",
374            commit_hash,
375            branch
376        );
377
378        // Create debug session
379        let mut debug_session = DebugSession::new(debug_config);
380        debug_session.start().await?;
381
382        // Generate debug report -- this performs the real analysis (tensor
383        // inspection, gradient debugging, memory profiling, ...); there is
384        // nothing left to simulate here.
385        let debug_report = debug_session.stop().await?;
386        let analysis_duration_ms = (Utc::now() - start_time).num_milliseconds().max(0) as u64;
387
388        // Extract metrics for quality gates and regression detection
389        let metrics = self.extract_metrics_from_report(&debug_report, analysis_duration_ms);
390
391        // Run quality gates
392        let quality_gate_results = self.evaluate_quality_gates(&metrics);
393
394        // Check for regressions
395        let regression_results = self.detect_regressions(&metrics, &commit_hash);
396
397        // Determine overall status
398        let status = self.determine_pipeline_status(&quality_gate_results, &regression_results);
399
400        // Create performance data
401        let performance_data = PerformanceData {
402            timestamp: start_time,
403            commit_hash: commit_hash.clone(),
404            branch: branch.clone(),
405            metrics: metrics.clone(),
406            benchmark_results: self.generate_benchmark_results(&metrics),
407        };
408
409        // Generate artifacts
410        let artifacts = self.generate_artifacts(&debug_report, &performance_data)?;
411
412        let duration_ms = (Utc::now() - start_time).num_milliseconds() as u64;
413
414        let result = PipelineResult {
415            run_id,
416            timestamp: start_time,
417            commit_hash,
418            branch,
419            stage: PipelineStage::Debug,
420            status: status.clone(),
421            debug_report: Some(debug_report),
422            quality_gate_results,
423            regression_results,
424            performance_data: Some(performance_data.clone()),
425            artifacts,
426            duration_ms,
427        };
428
429        // Store results
430        self.performance_history.push(performance_data);
431        self.pipeline_history.push(result.clone());
432
433        // Send notifications if configured
434        if self.config.enable_alert_systems {
435            self.send_notifications(&result).await?;
436        }
437
438        // Generate reports if configured
439        if self.config.enable_automated_reports {
440            self.generate_reports(&result).await?;
441        }
442
443        tracing::info!("Debug analysis completed with status: {:?}", status);
444
445        Ok(result)
446    }
447
448    /// Extract metrics from debug report.
449    ///
450    /// `analysis_duration_ms` is the real wall-clock time
451    /// [`Self::run_debug_analysis`] spent running the debug session, passed
452    /// in explicitly since this function has no other way to measure it.
453    fn extract_metrics_from_report(
454        &self,
455        report: &DebugReport,
456        analysis_duration_ms: u64,
457    ) -> HashMap<String, f64> {
458        let mut metrics = HashMap::new();
459
460        // Extract tensor metrics
461        if let Some(ref tensor_report) = report.tensor_report {
462            metrics.insert(
463                "tensor_nan_count".to_string(),
464                tensor_report.total_nan_count() as f64,
465            );
466            metrics.insert(
467                "tensor_inf_count".to_string(),
468                tensor_report.total_inf_count() as f64,
469            );
470        }
471
472        // Extract gradient metrics
473        if let Some(ref gradient_report) = report.gradient_report {
474            metrics.insert(
475                "gradient_norm".to_string(),
476                gradient_report.average_gradient_norm(),
477            );
478            metrics.insert(
479                "vanishing_gradients".to_string(),
480                gradient_report.vanishing_gradient_layers().len() as f64,
481            );
482            metrics.insert(
483                "exploding_gradients".to_string(),
484                gradient_report.exploding_gradient_layers().len() as f64,
485            );
486        }
487
488        // Extract memory metrics
489        if let Some(ref memory_report) = report.memory_profiler_report {
490            metrics.insert(
491                "peak_memory_mb".to_string(),
492                memory_report.peak_memory_usage() / (1024.0 * 1024.0),
493            );
494            metrics.insert(
495                "memory_efficiency".to_string(),
496                memory_report.memory_efficiency(),
497            );
498        }
499
500        // Extract performance metrics. `total_parameters` is only inserted
501        // when a real count is available (see `count_model_parameters`) --
502        // never a fabricated constant.
503        if let Some(total_parameters) = self.count_model_parameters(report) {
504            metrics.insert("total_parameters".to_string(), total_parameters as f64);
505        }
506        metrics.insert("training_time_ms".to_string(), analysis_duration_ms as f64);
507
508        metrics
509    }
510
511    /// Evaluate quality gates against metrics
512    fn evaluate_quality_gates(&self, metrics: &HashMap<String, f64>) -> Vec<QualityGateResult> {
513        let mut results = Vec::new();
514
515        for gate in &self.quality_gates {
516            let metric_name = self.get_metric_name(&gate.metric);
517            let actual_value = metrics.get(&metric_name).copied().unwrap_or(0.0);
518
519            let passed = match gate.operator {
520                ComparisonOperator::GreaterThan => actual_value > gate.threshold,
521                ComparisonOperator::LessThan => actual_value < gate.threshold,
522                ComparisonOperator::GreaterThanOrEqual => actual_value >= gate.threshold,
523                ComparisonOperator::LessThanOrEqual => actual_value <= gate.threshold,
524                ComparisonOperator::Equal => (actual_value - gate.threshold).abs() < f64::EPSILON,
525                ComparisonOperator::NotEqual => {
526                    (actual_value - gate.threshold).abs() >= f64::EPSILON
527                },
528            };
529
530            let status = if passed { QualityGateStatus::Passed } else { QualityGateStatus::Failed };
531
532            let message = format!(
533                "Quality gate '{}': {} {} {} (actual: {})",
534                gate.name,
535                metric_name,
536                self.operator_symbol(&gate.operator),
537                gate.threshold,
538                actual_value
539            );
540
541            results.push(QualityGateResult {
542                gate: gate.clone(),
543                status,
544                actual_value,
545                message,
546            });
547        }
548
549        results
550    }
551
552    /// Detect regressions by comparing current metrics with baseline
553    fn detect_regressions(
554        &self,
555        metrics: &HashMap<String, f64>,
556        _commit_hash: &str,
557    ) -> Vec<RegressionResult> {
558        let mut results = Vec::new();
559
560        if !self.config.enable_regression_detection {
561            return results;
562        }
563
564        for (metric_name, &current_value) in metrics {
565            if let Some(&baseline_value) = self.baseline_metrics.get(metric_name) {
566                let change_percent = ((current_value - baseline_value) / baseline_value) * 100.0;
567
568                // Determine if this is a regression based on metric type and change
569                let (detected, severity) = self.analyze_regression(metric_name, change_percent);
570
571                if detected {
572                    results.push(RegressionResult {
573                        detected: true,
574                        severity,
575                        metric: metric_name.clone(),
576                        baseline_value,
577                        current_value,
578                        change_percent,
579                        description: format!(
580                            "Regression detected in {}: {:.2}% change from baseline (baseline: {:.4}, current: {:.4})",
581                            metric_name, change_percent, baseline_value, current_value
582                        ),
583                    });
584                }
585            }
586        }
587
588        results
589    }
590
591    /// Analyze if a metric change constitutes a regression
592    fn analyze_regression(
593        &self,
594        metric_name: &str,
595        change_percent: f64,
596    ) -> (bool, RegressionSeverity) {
597        let abs_change = change_percent.abs();
598
599        // Define regression thresholds based on metric type
600        let (minor_threshold, major_threshold, critical_threshold) = match metric_name {
601            name if name.contains("accuracy") => (2.0, 5.0, 10.0),
602            name if name.contains("loss") => (5.0, 15.0, 30.0),
603            name if name.contains("memory") => (10.0, 25.0, 50.0),
604            name if name.contains("time") => (15.0, 30.0, 60.0),
605            _ => (5.0, 15.0, 30.0), // Default thresholds
606        };
607
608        if abs_change >= critical_threshold {
609            (true, RegressionSeverity::Critical)
610        } else if abs_change >= major_threshold {
611            (true, RegressionSeverity::Major)
612        } else if abs_change >= minor_threshold {
613            (true, RegressionSeverity::Minor)
614        } else {
615            (false, RegressionSeverity::Info)
616        }
617    }
618
619    /// Determine overall pipeline status
620    fn determine_pipeline_status(
621        &self,
622        quality_gate_results: &[QualityGateResult],
623        regression_results: &[RegressionResult],
624    ) -> PipelineStatus {
625        // Check for blocking quality gate failures
626        for result in quality_gate_results {
627            if result.gate.blocking && matches!(result.status, QualityGateStatus::Failed) {
628                return PipelineStatus::Failed;
629            }
630        }
631
632        // Check for critical regressions
633        for regression in regression_results {
634            if matches!(regression.severity, RegressionSeverity::Critical) {
635                return PipelineStatus::Failed;
636            }
637        }
638
639        // Check for major regressions or non-blocking quality gate failures
640        let has_warnings = quality_gate_results
641            .iter()
642            .any(|r| matches!(r.status, QualityGateStatus::Failed))
643            || regression_results
644                .iter()
645                .any(|r| matches!(r.severity, RegressionSeverity::Major));
646
647        if has_warnings {
648            PipelineStatus::Warning
649        } else {
650            PipelineStatus::Success
651        }
652    }
653
654    /// Generate benchmark results
655    fn generate_benchmark_results(&self, metrics: &HashMap<String, f64>) -> Vec<BenchmarkResult> {
656        let mut results = Vec::new();
657
658        for (name, &value) in metrics {
659            let baseline = self.baseline_metrics.get(name).copied();
660            let improvement_percent = baseline.map(|b| ((value - b) / b) * 100.0);
661
662            let unit = match name.as_str() {
663                name if name.contains("time") || name.contains("latency") => "ms",
664                name if name.contains("memory") => "MB",
665                name if name.contains("accuracy") => "%",
666                name if name.contains("loss") => "loss",
667                _ => "units",
668            };
669
670            results.push(BenchmarkResult {
671                name: name.clone(),
672                value,
673                unit: unit.to_string(),
674                baseline,
675                improvement_percent,
676            });
677        }
678
679        results
680    }
681
682    /// Generate artifacts from analysis results
683    fn generate_artifacts(
684        &self,
685        debug_report: &DebugReport,
686        performance_data: &PerformanceData,
687    ) -> Result<Vec<Artifact>> {
688        let mut artifacts = Vec::new();
689
690        // Generate debug report artifact
691        let debug_report_json = serde_json::to_string_pretty(debug_report)?;
692        let debug_report_path = PathBuf::from("debug_report.json");
693        std::fs::write(&debug_report_path, &debug_report_json)?;
694
695        artifacts.push(Artifact {
696            name: "Debug Report".to_string(),
697            path: debug_report_path,
698            size_bytes: debug_report_json.len() as u64,
699            checksum: format!("{:x}", md5::compute(&debug_report_json)),
700            artifact_type: ArtifactType::DebugReport,
701        });
702
703        // Generate performance data artifact
704        let performance_json = serde_json::to_string_pretty(performance_data)?;
705        let performance_path = PathBuf::from("performance_data.json");
706        std::fs::write(&performance_path, &performance_json)?;
707
708        artifacts.push(Artifact {
709            name: "Performance Data".to_string(),
710            path: performance_path,
711            size_bytes: performance_json.len() as u64,
712            checksum: format!("{:x}", md5::compute(&performance_json)),
713            artifact_type: ArtifactType::BenchmarkResults,
714        });
715
716        Ok(artifacts)
717    }
718
719    /// Send notifications based on pipeline result
720    async fn send_notifications(&self, result: &PipelineResult) -> Result<()> {
721        for channel in &self.config.notification_channels {
722            match channel {
723                NotificationChannel::Slack {
724                    webhook_url,
725                    channel: slack_channel,
726                } => {
727                    self.send_slack_notification(webhook_url, slack_channel, result).await?;
728                },
729                NotificationChannel::Email { recipients, api } => {
730                    self.send_email_notification(recipients, api.as_ref(), result).await?;
731                },
732                NotificationChannel::Teams { webhook_url } => {
733                    self.send_teams_notification(webhook_url, result).await?;
734                },
735                NotificationChannel::Discord { webhook_url } => {
736                    self.send_discord_notification(webhook_url, result).await?;
737                },
738                NotificationChannel::Webhook { url, headers } => {
739                    self.send_webhook_notification(url, headers, result).await?;
740                },
741                NotificationChannel::Custom(_) => {
742                    return Err(NotificationError::NotImplemented("Custom").into());
743                },
744            }
745        }
746
747        Ok(())
748    }
749
750    /// Generate reports in various formats
751    async fn generate_reports(&self, result: &PipelineResult) -> Result<()> {
752        for format in &self.config.report_formats {
753            match format {
754                ReportFormat::JSON => {
755                    let json_report = serde_json::to_string_pretty(result)?;
756                    std::fs::write("cicd_report.json", json_report)?;
757                },
758                ReportFormat::HTML => {
759                    let html_report = self.generate_html_report(result)?;
760                    std::fs::write("cicd_report.html", html_report)?;
761                },
762                ReportFormat::Markdown => {
763                    let md_report = self.generate_markdown_report(result)?;
764                    std::fs::write("cicd_report.md", md_report)?;
765                },
766                ReportFormat::JUnit => {
767                    let junit_report = self.generate_junit_report(result)?;
768                    std::fs::write("cicd_report.xml", junit_report)?;
769                },
770                _ => {
771                    tracing::info!("Report format {:?} not implemented", format);
772                },
773            }
774        }
775
776        Ok(())
777    }
778
779    /// Helper methods for notification and report generation.
780    ///
781    /// Every sender builds its real payload unconditionally (so callers,
782    /// tests and mocks all see the exact JSON that would be sent), then
783    /// delivers it via [`post_json`] when the `http-integrations` feature is
784    /// enabled. Without that feature no HTTP client exists in this build,
785    /// so delivery honestly fails with
786    /// [`NotificationError::HttpFeatureDisabled`] rather than pretending to
787    /// have sent anything.
788    async fn send_slack_notification(
789        &self,
790        webhook_url: &str,
791        channel: &str,
792        result: &PipelineResult,
793    ) -> Result<()> {
794        let color = match result.status {
795            PipelineStatus::Success => "good",
796            PipelineStatus::Warning => "warning",
797            PipelineStatus::Failed => "danger",
798            _ => "warning",
799        };
800
801        let message = serde_json::json!({
802            "channel": channel,
803            "attachments": [{
804                "color": color,
805                "title": format!("Debug Analysis - {}", result.commit_hash),
806                "text": format!("Branch: {} | Status: {:?} | Duration: {}ms",
807                    result.branch, result.status, result.duration_ms),
808                "fields": [
809                    {
810                        "title": "Quality Gates",
811                        "value": format!("{} passed, {} failed",
812                            result.quality_gate_results.iter().filter(|r| matches!(r.status, QualityGateStatus::Passed)).count(),
813                            result.quality_gate_results.iter().filter(|r| matches!(r.status, QualityGateStatus::Failed)).count()),
814                        "short": true
815                    },
816                    {
817                        "title": "Regressions",
818                        "value": format!("{} detected", result.regression_results.len()),
819                        "short": true
820                    }
821                ]
822            }]
823        });
824
825        post_json(webhook_url, &message, &HashMap::new()).await?;
826        tracing::info!(webhook_url = %webhook_url, "Slack notification delivered");
827        Ok(())
828    }
829
830    async fn send_email_notification(
831        &self,
832        recipients: &[String],
833        api: Option<&EmailApiConfig>,
834        result: &PipelineResult,
835    ) -> Result<()> {
836        let Some(api) = api else {
837            return Err(NotificationError::EmailNotConfigured.into());
838        };
839
840        let subject = format!(
841            "Debug Analysis Report - {} ({})",
842            result.commit_hash, result.status
843        );
844        let body = format!(
845            "Debug analysis completed for commit {} on branch {}.\n\nStatus: {:?}\nDuration: {}ms\n\nQuality Gates: {} passed, {} failed\nRegressions: {} detected",
846            result.commit_hash,
847            result.branch,
848            result.status,
849            result.duration_ms,
850            result.quality_gate_results.iter().filter(|r| matches!(r.status, QualityGateStatus::Passed)).count(),
851            result.quality_gate_results.iter().filter(|r| matches!(r.status, QualityGateStatus::Failed)).count(),
852            result.regression_results.len()
853        );
854
855        let payload = serde_json::json!({
856            "to": recipients,
857            "subject": subject,
858            "body": body,
859        });
860
861        post_json(&api.endpoint, &payload, &api.headers).await?;
862        tracing::info!(recipients = ?recipients, endpoint = %api.endpoint, "Email notification delivered");
863        Ok(())
864    }
865
866    async fn send_teams_notification(
867        &self,
868        webhook_url: &str,
869        result: &PipelineResult,
870    ) -> Result<()> {
871        // Office 365 Connector "MessageCard" format.
872        let theme_color = match result.status {
873            PipelineStatus::Success => "28A745",
874            PipelineStatus::Warning => "FFC107",
875            PipelineStatus::Failed => "DC3545",
876            _ => "6C757D",
877        };
878
879        let message = serde_json::json!({
880            "@type": "MessageCard",
881            "@context": "http://schema.org/extensions",
882            "themeColor": theme_color,
883            "summary": format!("Debug Analysis - {}", result.commit_hash),
884            "sections": [{
885                "activityTitle": format!("Debug Analysis - {}", result.commit_hash),
886                "facts": [
887                    {"name": "Branch", "value": result.branch},
888                    {"name": "Status", "value": format!("{:?}", result.status)},
889                    {"name": "Duration", "value": format!("{}ms", result.duration_ms)},
890                    {"name": "Quality Gates", "value": format!("{} passed, {} failed",
891                        result.quality_gate_results.iter().filter(|r| matches!(r.status, QualityGateStatus::Passed)).count(),
892                        result.quality_gate_results.iter().filter(|r| matches!(r.status, QualityGateStatus::Failed)).count())},
893                    {"name": "Regressions", "value": format!("{} detected", result.regression_results.len())},
894                ],
895            }],
896        });
897
898        post_json(webhook_url, &message, &HashMap::new()).await?;
899        tracing::info!(webhook_url = %webhook_url, "Teams notification delivered");
900        Ok(())
901    }
902
903    async fn send_discord_notification(
904        &self,
905        webhook_url: &str,
906        result: &PipelineResult,
907    ) -> Result<()> {
908        // Discord webhook "embeds" format.
909        let color = match result.status {
910            PipelineStatus::Success => 0x28_A7_45,
911            PipelineStatus::Warning => 0xFF_C1_07,
912            PipelineStatus::Failed => 0xDC_35_45,
913            _ => 0x6C_75_7D,
914        };
915
916        let message = serde_json::json!({
917            "embeds": [{
918                "title": format!("Debug Analysis - {}", result.commit_hash),
919                "description": format!("Branch: {} | Status: {:?} | Duration: {}ms",
920                    result.branch, result.status, result.duration_ms),
921                "color": color,
922                "fields": [
923                    {
924                        "name": "Quality Gates",
925                        "value": format!("{} passed, {} failed",
926                            result.quality_gate_results.iter().filter(|r| matches!(r.status, QualityGateStatus::Passed)).count(),
927                            result.quality_gate_results.iter().filter(|r| matches!(r.status, QualityGateStatus::Failed)).count()),
928                        "inline": true
929                    },
930                    {
931                        "name": "Regressions",
932                        "value": format!("{} detected", result.regression_results.len()),
933                        "inline": true
934                    }
935                ]
936            }]
937        });
938
939        post_json(webhook_url, &message, &HashMap::new()).await?;
940        tracing::info!(webhook_url = %webhook_url, "Discord notification delivered");
941        Ok(())
942    }
943
944    async fn send_webhook_notification(
945        &self,
946        url: &str,
947        headers: &HashMap<String, String>,
948        result: &PipelineResult,
949    ) -> Result<()> {
950        let payload = serde_json::to_value(result)?;
951        post_json(url, &payload, headers).await?;
952        tracing::info!(url = %url, "Generic webhook notification delivered");
953        Ok(())
954    }
955
956    /// Generate HTML report
957    fn generate_html_report(&self, result: &PipelineResult) -> Result<String> {
958        let html = format!(r#"
959<!DOCTYPE html>
960<html>
961<head>
962    <title>Debug Analysis Report</title>
963    <style>
964        body {{ font-family: Arial, sans-serif; margin: 20px; }}
965        .status-success {{ color: green; }}
966        .status-warning {{ color: orange; }}
967        .status-failed {{ color: red; }}
968        table {{ border-collapse: collapse; width: 100%; }}
969        th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
970        th {{ background-color: #f2f2f2; }}
971    </style>
972</head>
973<body>
974    <h1>Debug Analysis Report</h1>
975    <h2>Overview</h2>
976    <p><strong>Commit:</strong> {}</p>
977    <p><strong>Branch:</strong> {}</p>
978    <p><strong>Status:</strong> <span class="status-{}">{:?}</span></p>
979    <p><strong>Duration:</strong> {}ms</p>
980    <p><strong>Timestamp:</strong> {}</p>
981
982    <h2>Quality Gates</h2>
983    <table>
984        <tr><th>Gate</th><th>Status</th><th>Actual Value</th><th>Threshold</th><th>Message</th></tr>
985        {}
986    </table>
987
988    <h2>Regression Analysis</h2>
989    <table>
990        <tr><th>Metric</th><th>Severity</th><th>Change %</th><th>Baseline</th><th>Current</th><th>Description</th></tr>
991        {}
992    </table>
993</body>
994</html>
995"#,
996            result.commit_hash,
997            result.branch,
998            format!("{:?}", result.status).to_lowercase(),
999            result.status,
1000            result.duration_ms,
1001            result.timestamp,
1002            result.quality_gate_results.iter().map(|r| format!(
1003                "<tr><td>{}</td><td>{:?}</td><td>{:.4}</td><td>{:.4}</td><td>{}</td></tr>",
1004                r.gate.name, r.status, r.actual_value, r.gate.threshold, r.message
1005            )).collect::<Vec<_>>().join(""),
1006            result.regression_results.iter().map(|r| format!(
1007                "<tr><td>{}</td><td>{:?}</td><td>{:.2}%</td><td>{:.4}</td><td>{:.4}</td><td>{}</td></tr>",
1008                r.metric, r.severity, r.change_percent, r.baseline_value, r.current_value, r.description
1009            )).collect::<Vec<_>>().join("")
1010        );
1011
1012        Ok(html)
1013    }
1014
1015    /// Generate Markdown report
1016    fn generate_markdown_report(&self, result: &PipelineResult) -> Result<String> {
1017        let status_emoji = match result.status {
1018            PipelineStatus::Success => "✅",
1019            PipelineStatus::Warning => "⚠️",
1020            PipelineStatus::Failed => "❌",
1021            _ => "❓",
1022        };
1023
1024        let markdown = format!(
1025            r#"# Debug Analysis Report
1026
1027## Overview
1028- **Commit:** {}
1029- **Branch:** {}
1030- **Status:** {} {:?}
1031- **Duration:** {}ms
1032- **Timestamp:** {}
1033
1034## Quality Gates
1035| Gate | Status | Actual Value | Threshold | Message |
1036|------|--------|--------------|-----------|---------|
1037{}
1038
1039## Regression Analysis
1040| Metric | Severity | Change % | Baseline | Current | Description |
1041|--------|----------|----------|----------|---------|-------------|
1042{}
1043
1044## Artifacts
1045{}
1046"#,
1047            result.commit_hash,
1048            result.branch,
1049            status_emoji,
1050            result.status,
1051            result.duration_ms,
1052            result.timestamp,
1053            result
1054                .quality_gate_results
1055                .iter()
1056                .map(|r| format!(
1057                    "| {} | {:?} | {:.4} | {:.4} | {} |",
1058                    r.gate.name, r.status, r.actual_value, r.gate.threshold, r.message
1059                ))
1060                .collect::<Vec<_>>()
1061                .join("\n"),
1062            result
1063                .regression_results
1064                .iter()
1065                .map(|r| format!(
1066                    "| {} | {:?} | {:.2}% | {:.4} | {:.4} | {} |",
1067                    r.metric,
1068                    r.severity,
1069                    r.change_percent,
1070                    r.baseline_value,
1071                    r.current_value,
1072                    r.description
1073                ))
1074                .collect::<Vec<_>>()
1075                .join("\n"),
1076            result
1077                .artifacts
1078                .iter()
1079                .map(|a| format!(
1080                    "- **{}:** {} ({} bytes)",
1081                    a.name,
1082                    a.path.display(),
1083                    a.size_bytes
1084                ))
1085                .collect::<Vec<_>>()
1086                .join("\n")
1087        );
1088
1089        Ok(markdown)
1090    }
1091
1092    /// Generate JUnit XML report
1093    fn generate_junit_report(&self, result: &PipelineResult) -> Result<String> {
1094        let test_cases = result
1095            .quality_gate_results
1096            .iter()
1097            .map(|r| {
1098                let status = match r.status {
1099                    QualityGateStatus::Passed => "",
1100                    QualityGateStatus::Failed => {
1101                        r#"<failure message="Quality gate failed"></failure>"#
1102                    },
1103                    QualityGateStatus::Warning => {
1104                        r#"<error message="Quality gate warning"></error>"#
1105                    },
1106                    QualityGateStatus::Skipped => r#"<skipped/>"#,
1107                };
1108                format!(
1109                    r#"<testcase classname="QualityGates" name="{}" time="0">{}</testcase>"#,
1110                    r.gate.name, status
1111                )
1112            })
1113            .collect::<Vec<_>>()
1114            .join("\n    ");
1115
1116        let junit = format!(
1117            r#"<?xml version="1.0" encoding="UTF-8"?>
1118<testsuite name="DebugAnalysis" tests="{}" failures="{}" errors="0" time="{:.3}">
1119    {}
1120</testsuite>
1121"#,
1122            result.quality_gate_results.len(),
1123            result
1124                .quality_gate_results
1125                .iter()
1126                .filter(|r| matches!(r.status, QualityGateStatus::Failed))
1127                .count(),
1128            result.duration_ms as f64 / 1000.0,
1129            test_cases
1130        );
1131
1132        Ok(junit)
1133    }
1134
1135    /// Helper methods
1136    fn get_metric_name(&self, metric: &QualityMetric) -> String {
1137        match metric {
1138            QualityMetric::TestCoverage => "test_coverage".to_string(),
1139            QualityMetric::ModelAccuracy => "model_accuracy".to_string(),
1140            QualityMetric::TrainingLoss => "training_loss".to_string(),
1141            QualityMetric::GradientNorm => "gradient_norm".to_string(),
1142            QualityMetric::MemoryUsage => "peak_memory_mb".to_string(),
1143            QualityMetric::TrainingTime => "training_time_ms".to_string(),
1144            QualityMetric::ModelSize => "total_parameters".to_string(),
1145            QualityMetric::InferenceLatency => "inference_latency_ms".to_string(),
1146            QualityMetric::Custom(name) => name.clone(),
1147        }
1148    }
1149
1150    fn operator_symbol(&self, op: &ComparisonOperator) -> &'static str {
1151        match op {
1152            ComparisonOperator::GreaterThan => ">",
1153            ComparisonOperator::LessThan => "<",
1154            ComparisonOperator::GreaterThanOrEqual => ">=",
1155            ComparisonOperator::LessThanOrEqual => "<=",
1156            ComparisonOperator::Equal => "==",
1157            ComparisonOperator::NotEqual => "!=",
1158        }
1159    }
1160
1161    /// Real parameter count, if one is reachable from the debug report.
1162    /// `DebugReport` does not currently carry model architecture metadata
1163    /// (see `ModelDiagnosticsReport`/`ArchitecturalAnalysis`), so this
1164    /// honestly returns `None` rather than fabricating a number; callers
1165    /// must simply omit the `total_parameters` metric until a real source
1166    /// is wired through the debug session.
1167    fn count_model_parameters(&self, _report: &DebugReport) -> Option<u64> {
1168        None
1169    }
1170
1171    /// Get pipeline history
1172    pub fn get_pipeline_history(&self) -> &[PipelineResult] {
1173        &self.pipeline_history
1174    }
1175
1176    /// Get performance history
1177    pub fn get_performance_history(&self) -> &[PerformanceData] {
1178        &self.performance_history
1179    }
1180
1181    /// Get quality gates
1182    pub fn get_quality_gates(&self) -> &[QualityGate] {
1183        &self.quality_gates
1184    }
1185}
1186
1187impl Default for CICDConfig {
1188    fn default() -> Self {
1189        Self {
1190            platform: CICDPlatform::GitHub,
1191            project_id: "default".to_string(),
1192            api_token: None,
1193            base_url: None,
1194            branch_filters: vec!["main".to_string(), "develop".to_string()],
1195            enable_regression_detection: true,
1196            enable_performance_tracking: true,
1197            enable_quality_gates: true,
1198            enable_automated_reports: true,
1199            enable_alert_systems: true,
1200            report_formats: vec![
1201                ReportFormat::JSON,
1202                ReportFormat::HTML,
1203                ReportFormat::Markdown,
1204            ],
1205            notification_channels: Vec::new(),
1206        }
1207    }
1208}
1209
1210// Additional accessor implementations used when extracting CI/CD metrics
1211// from a debug report. `DebugReport` itself intentionally has no
1212// `total_nan_count`/`total_inf_count` here: those real, data-derived
1213// methods already live on `TensorInspectionReport`
1214// (`report.tensor_report.total_nan_count()`), which is what
1215// `extract_metrics_from_report` actually calls.
1216
1217impl crate::GradientDebugReport {
1218    /// Real mean of the latest per-layer gradient norms. `0.0` when no layer
1219    /// status has been recorded yet (rather than a fabricated `1.0`).
1220    pub fn average_gradient_norm(&self) -> f64 {
1221        let norms: Vec<f64> =
1222            self.status.layer_statuses.values().map(|s| s.latest_gradient_norm).collect();
1223        mean_gradient_norm(&norms)
1224    }
1225
1226    /// Real layer names whose latest gradient norm is vanishing, using the
1227    /// same `1e-8` threshold as [`Self::has_vanishing_gradients`].
1228    pub fn vanishing_gradient_layers(&self) -> Vec<String> {
1229        let norms: HashMap<String, f64> = self
1230            .status
1231            .layer_statuses
1232            .iter()
1233            .map(|(name, status)| (name.clone(), status.latest_gradient_norm))
1234            .collect();
1235        layers_matching(&norms, |norm| norm < 1e-8)
1236    }
1237
1238    /// Real layer names whose latest gradient norm is exploding, using the
1239    /// same `100.0` threshold as [`Self::has_exploding_gradients`].
1240    pub fn exploding_gradient_layers(&self) -> Vec<String> {
1241        let norms: HashMap<String, f64> = self
1242            .status
1243            .layer_statuses
1244            .iter()
1245            .map(|(name, status)| (name.clone(), status.latest_gradient_norm))
1246            .collect();
1247        layers_matching(&norms, |norm| norm > 100.0)
1248    }
1249}
1250
1251/// Real mean of a set of gradient norms. `0.0` when empty (rather than a
1252/// fabricated default).
1253fn mean_gradient_norm(norms: &[f64]) -> f64 {
1254    if norms.is_empty() {
1255        0.0
1256    } else {
1257        norms.iter().sum::<f64>() / norms.len() as f64
1258    }
1259}
1260
1261/// Names of layers whose norm satisfies `predicate` -- shared by both
1262/// vanishing- and exploding-gradient detection so they stay in lock-step.
1263fn layers_matching(norms: &HashMap<String, f64>, predicate: impl Fn(f64) -> bool) -> Vec<String> {
1264    norms
1265        .iter()
1266        .filter(|(_, &norm)| predicate(norm))
1267        .map(|(name, _)| name.clone())
1268        .collect()
1269}
1270
1271impl crate::MemoryProfilingReport {
1272    /// Real peak memory usage in bytes, derived from the already-real
1273    /// `peak_memory_mb` field this report is built from.
1274    pub fn peak_memory_usage(&self) -> f64 {
1275        self.peak_memory_mb * 1024.0 * 1024.0
1276    }
1277
1278    /// Real memory-efficiency proxy: `1 - fragmentation_ratio`, both drawn
1279    /// from this report's already-real fragmentation analysis.
1280    pub fn memory_efficiency(&self) -> f64 {
1281        (1.0 - self.fragmentation_analysis.fragmentation_ratio).clamp(0.0, 1.0)
1282    }
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::*;
1288
1289    fn sample_result(status: PipelineStatus) -> PipelineResult {
1290        PipelineResult {
1291            run_id: Uuid::new_v4(),
1292            timestamp: Utc::now(),
1293            commit_hash: "abc123".to_string(),
1294            branch: "main".to_string(),
1295            stage: PipelineStage::Debug,
1296            status,
1297            debug_report: None,
1298            quality_gate_results: Vec::new(),
1299            regression_results: Vec::new(),
1300            performance_data: None,
1301            artifacts: Vec::new(),
1302            duration_ms: 42,
1303        }
1304    }
1305
1306    // ------------------------------------------------------------------
1307    // Real per-report accessor methods (no longer hardcoded placeholders)
1308    // ------------------------------------------------------------------
1309
1310    #[test]
1311    fn test_mean_gradient_norm_is_real_not_fixed_one() {
1312        // The old implementation always returned 1.0 regardless of input.
1313        assert_eq!(mean_gradient_norm(&[]), 0.0);
1314        assert_eq!(mean_gradient_norm(&[2.0, 4.0, 6.0]), 4.0);
1315        assert_ne!(mean_gradient_norm(&[2.0, 4.0, 6.0]), 1.0);
1316    }
1317
1318    #[test]
1319    fn test_layers_matching_finds_real_vanishing_and_exploding_layers() {
1320        let mut norms = HashMap::new();
1321        norms.insert("healthy".to_string(), 0.5);
1322        norms.insert("vanished".to_string(), 1e-10);
1323        norms.insert("exploded".to_string(), 500.0);
1324
1325        // The old implementation always returned an empty Vec regardless of
1326        // input, for both vanishing and exploding layers.
1327        let vanishing = layers_matching(&norms, |n| n < 1e-8);
1328        assert_eq!(vanishing, vec!["vanished".to_string()]);
1329
1330        let exploding = layers_matching(&norms, |n| n > 100.0);
1331        assert_eq!(exploding, vec!["exploded".to_string()]);
1332    }
1333
1334    #[tokio::test]
1335    async fn test_memory_report_accessors_reflect_real_profiler_fields() {
1336        use crate::memory_profiler::{AllocationType, MemoryProfiler, MemoryProfilingConfig};
1337
1338        let mut profiler = MemoryProfiler::new(MemoryProfilingConfig::default());
1339        profiler.start().await.expect("profiler should start");
1340        let id = profiler
1341            .record_allocation(4096, AllocationType::Tensor, vec!["test".to_string()])
1342            .expect("allocation should record");
1343        profiler.record_deallocation(id).expect("deallocation should record");
1344        let report = profiler.stop().await.expect("profiler should stop");
1345
1346        // The old implementation ignored the report entirely and always
1347        // returned a hardcoded 100MB / 0.85, regardless of its real fields.
1348        assert_eq!(
1349            report.peak_memory_usage(),
1350            report.peak_memory_mb * 1024.0 * 1024.0
1351        );
1352        assert_eq!(
1353            report.memory_efficiency(),
1354            (1.0 - report.fragmentation_analysis.fragmentation_ratio).clamp(0.0, 1.0)
1355        );
1356    }
1357
1358    #[tokio::test]
1359    async fn test_count_model_parameters_is_honest_absence_not_a_million() {
1360        let integration = CICDIntegration::new(CICDConfig::default());
1361        let mut session = DebugSession::new(DebugConfig::default());
1362        session.start().await.expect("session should start");
1363        let report = session.stop().await.expect("session should stop");
1364
1365        // The old implementation always returned 1_000_000 regardless of
1366        // whether any model was ever involved.
1367        assert_eq!(integration.count_model_parameters(&report), None);
1368    }
1369
1370    // ------------------------------------------------------------------
1371    // Notification delivery: real payload shape via a local mock server,
1372    // and an honest, structured failure when the feature is disabled.
1373    // ------------------------------------------------------------------
1374
1375    #[cfg(feature = "http-integrations")]
1376    mod http_delivery {
1377        use super::*;
1378        use axum::{extract::State, routing::post, Json, Router};
1379        use std::sync::Arc;
1380        use tokio::net::TcpListener;
1381        use tokio::sync::Mutex as AsyncMutex;
1382
1383        #[derive(Default, Clone)]
1384        struct Captured {
1385            body: Option<serde_json::Value>,
1386        }
1387
1388        async fn capture(
1389            State(state): State<Arc<AsyncMutex<Captured>>>,
1390            Json(body): Json<serde_json::Value>,
1391        ) -> &'static str {
1392            state.lock().await.body = Some(body);
1393            "ok"
1394        }
1395
1396        /// Start a real local HTTP server (127.0.0.1, OS-assigned port; never
1397        /// touches the network) that captures whatever JSON body is POSTed
1398        /// to `/hook`.
1399        async fn start_mock_server() -> (String, Arc<AsyncMutex<Captured>>) {
1400            let state = Arc::new(AsyncMutex::new(Captured::default()));
1401            let app = Router::new().route("/hook", post(capture)).with_state(state.clone());
1402            let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock server");
1403            let addr = listener.local_addr().expect("mock server local addr");
1404            tokio::spawn(async move {
1405                let _ = axum::serve(listener, app).await;
1406            });
1407            (format!("http://{addr}/hook"), state)
1408        }
1409
1410        #[tokio::test]
1411        async fn test_slack_notification_sends_real_payload() {
1412            let (url, state) = start_mock_server().await;
1413            let integration = CICDIntegration::new(CICDConfig::default());
1414            let result = sample_result(PipelineStatus::Success);
1415
1416            integration
1417                .send_slack_notification(&url, "#ci-alerts", &result)
1418                .await
1419                .expect("mock server should accept the request");
1420
1421            let captured = state.lock().await.body.clone().expect("payload should be captured");
1422            assert_eq!(captured["channel"], "#ci-alerts");
1423            assert_eq!(captured["attachments"][0]["color"], "good");
1424            assert!(captured["attachments"][0]["title"]
1425                .as_str()
1426                .expect("title should be a string")
1427                .contains(&result.commit_hash));
1428        }
1429
1430        #[tokio::test]
1431        async fn test_teams_notification_sends_real_payload() {
1432            let (url, state) = start_mock_server().await;
1433            let integration = CICDIntegration::new(CICDConfig::default());
1434            let result = sample_result(PipelineStatus::Failed);
1435
1436            integration
1437                .send_teams_notification(&url, &result)
1438                .await
1439                .expect("mock server should accept the request");
1440
1441            let captured = state.lock().await.body.clone().expect("payload should be captured");
1442            assert_eq!(captured["@type"], "MessageCard");
1443            assert_eq!(captured["themeColor"], "DC3545");
1444        }
1445
1446        #[tokio::test]
1447        async fn test_discord_notification_sends_real_payload() {
1448            let (url, state) = start_mock_server().await;
1449            let integration = CICDIntegration::new(CICDConfig::default());
1450            let result = sample_result(PipelineStatus::Warning);
1451
1452            integration
1453                .send_discord_notification(&url, &result)
1454                .await
1455                .expect("mock server should accept the request");
1456
1457            let captured = state.lock().await.body.clone().expect("payload should be captured");
1458            assert!(captured["embeds"][0]["title"]
1459                .as_str()
1460                .expect("title should be a string")
1461                .contains(&result.commit_hash));
1462        }
1463
1464        #[tokio::test]
1465        async fn test_generic_webhook_sends_real_pipeline_result() {
1466            let (url, state) = start_mock_server().await;
1467            let integration = CICDIntegration::new(CICDConfig::default());
1468            let result = sample_result(PipelineStatus::Success);
1469
1470            integration
1471                .send_webhook_notification(&url, &HashMap::new(), &result)
1472                .await
1473                .expect("mock server should accept the request");
1474
1475            let captured = state.lock().await.body.clone().expect("payload should be captured");
1476            assert_eq!(captured["commit_hash"], result.commit_hash);
1477            assert_eq!(captured["duration_ms"], result.duration_ms);
1478        }
1479
1480        #[tokio::test]
1481        async fn test_email_notification_sends_real_payload_when_configured() {
1482            let (url, state) = start_mock_server().await;
1483            let integration = CICDIntegration::new(CICDConfig::default());
1484            let result = sample_result(PipelineStatus::Success);
1485            let api = EmailApiConfig {
1486                endpoint: url,
1487                headers: HashMap::new(),
1488            };
1489
1490            integration
1491                .send_email_notification(&["dev@example.com".to_string()], Some(&api), &result)
1492                .await
1493                .expect("mock server should accept the request");
1494
1495            let captured = state.lock().await.body.clone().expect("payload should be captured");
1496            assert_eq!(captured["to"][0], "dev@example.com");
1497            assert!(captured["subject"]
1498                .as_str()
1499                .expect("subject should be a string")
1500                .contains(&result.commit_hash));
1501        }
1502    }
1503
1504    #[cfg(not(feature = "http-integrations"))]
1505    mod http_disabled {
1506        use super::*;
1507
1508        #[tokio::test]
1509        async fn test_slack_notification_fails_honestly_without_feature() {
1510            let integration = CICDIntegration::new(CICDConfig::default());
1511            let result = sample_result(PipelineStatus::Success);
1512
1513            let err = integration
1514                .send_slack_notification("http://127.0.0.1:1/hook", "#ci", &result)
1515                .await
1516                .expect_err("must not silently pretend to have sent anything");
1517            assert!(err.to_string().contains("http-integrations"));
1518        }
1519    }
1520
1521    #[tokio::test]
1522    async fn test_email_notification_is_honestly_not_configured_without_api() {
1523        let integration = CICDIntegration::new(CICDConfig::default());
1524        let result = sample_result(PipelineStatus::Success);
1525
1526        let err = integration
1527            .send_email_notification(&["dev@example.com".to_string()], None, &result)
1528            .await
1529            .expect_err("must not silently pretend to have sent an email");
1530        assert!(err.to_string().contains("endpoint configured"));
1531    }
1532
1533    #[tokio::test]
1534    async fn test_custom_notification_channel_errors_instead_of_silently_succeeding() {
1535        let mut config = CICDConfig::default();
1536        config.enable_alert_systems = true;
1537        config.notification_channels = vec![NotificationChannel::Custom("pagerduty".to_string())];
1538        let integration = CICDIntegration::new(config);
1539        let result = sample_result(PipelineStatus::Success);
1540
1541        // The old implementation logged "not implemented" and then returned
1542        // `Ok(())` from the whole dispatch loop -- a caller had no way to
1543        // tell the notification never went anywhere.
1544        let err = integration
1545            .send_notifications(&result)
1546            .await
1547            .expect_err("an unimplemented channel must not report success");
1548        assert!(err.to_string().contains("no delivery implementation"));
1549    }
1550}