use serde::{Deserialize, Serialize};
use super::{
BASELINE_VERSION, BaselineComplexitySection, BaselineMetrics, DeterminismBaseline,
FileBaselineEntry,
};
use crate::AnalysisReceipt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplexityBaseline {
pub baseline_version: u32,
pub generated_at: String,
pub commit: Option<String>,
pub metrics: BaselineMetrics,
pub files: Vec<FileBaselineEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub complexity: Option<BaselineComplexitySection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub determinism: Option<DeterminismBaseline>,
}
impl ComplexityBaseline {
pub fn new() -> Self {
Self {
baseline_version: BASELINE_VERSION,
generated_at: String::new(),
commit: None,
metrics: BaselineMetrics::default(),
files: Vec::new(),
complexity: None,
determinism: None,
}
}
pub fn from_analysis(receipt: &AnalysisReceipt) -> Self {
let generated_at = chrono_timestamp_iso8601(receipt.generated_at_ms);
let total_code_lines = receipt
.derived
.as_ref()
.map(|d| d.totals.code as u64)
.unwrap_or(0);
let total_files = receipt
.derived
.as_ref()
.map(|d| d.totals.files as u64)
.unwrap_or(0);
let (metrics, files, complexity) = if let Some(ref complexity_report) = receipt.complexity {
let metrics = BaselineMetrics {
total_code_lines,
total_files,
avg_cyclomatic: complexity_report.avg_cyclomatic,
max_cyclomatic: complexity_report.max_cyclomatic as u32,
avg_cognitive: complexity_report.avg_cognitive.unwrap_or(0.0),
max_cognitive: complexity_report.max_cognitive.unwrap_or(0) as u32,
avg_nesting_depth: complexity_report.avg_nesting_depth.unwrap_or(0.0),
max_nesting_depth: complexity_report.max_nesting_depth.unwrap_or(0) as u32,
function_count: complexity_report.total_functions as u64,
avg_function_length: complexity_report.avg_function_length,
};
let files: Vec<FileBaselineEntry> = complexity_report
.files
.iter()
.map(|f| FileBaselineEntry {
path: f.path.clone(),
code_lines: 0, cyclomatic: f.cyclomatic_complexity as u32,
cognitive: f.cognitive_complexity.unwrap_or(0) as u32,
max_nesting: f.max_nesting.unwrap_or(0) as u32,
function_count: f.function_count as u32,
content_hash: None,
})
.collect();
let complexity_section = BaselineComplexitySection {
total_functions: complexity_report.total_functions,
avg_function_length: complexity_report.avg_function_length,
max_function_length: complexity_report.max_function_length,
avg_cyclomatic: complexity_report.avg_cyclomatic,
max_cyclomatic: complexity_report.max_cyclomatic,
avg_cognitive: complexity_report.avg_cognitive,
max_cognitive: complexity_report.max_cognitive,
avg_nesting_depth: complexity_report.avg_nesting_depth,
max_nesting_depth: complexity_report.max_nesting_depth,
high_risk_files: complexity_report.high_risk_files,
};
(metrics, files, Some(complexity_section))
} else {
let fallback_metrics = BaselineMetrics {
total_code_lines,
total_files,
..Default::default()
};
(fallback_metrics, Vec::new(), None)
};
Self {
baseline_version: BASELINE_VERSION,
generated_at,
commit: None,
metrics,
files,
complexity,
determinism: None,
}
}
}
impl Default for ComplexityBaseline {
fn default() -> Self {
Self::new()
}
}
fn chrono_timestamp_iso8601(ms: u128) -> String {
let total_secs = (ms / 1000) as i64;
let millis = (ms % 1000) as u32;
const SECS_PER_MIN: i64 = 60;
const SECS_PER_HOUR: i64 = 3600;
const SECS_PER_DAY: i64 = 86400;
let days = total_secs / SECS_PER_DAY;
let day_secs = total_secs % SECS_PER_DAY;
let (days, day_secs) = if day_secs < 0 {
(days - 1, day_secs + SECS_PER_DAY)
} else {
(days, day_secs)
};
let hour = day_secs / SECS_PER_HOUR;
let min = (day_secs % SECS_PER_HOUR) / SECS_PER_MIN;
let sec = day_secs % SECS_PER_MIN;
let z = days + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
y, m, d, hour, min, sec, millis
)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{SecondsFormat, TimeZone, Utc};
use proptest::prelude::*;
#[test]
fn complexity_baseline_default() -> Result<(), Box<dyn std::error::Error>> {
let b = ComplexityBaseline::default();
assert_eq!(b.baseline_version, BASELINE_VERSION);
assert!(b.generated_at.is_empty());
assert!(b.commit.is_none());
assert!(b.files.is_empty());
assert!(b.complexity.is_none());
assert!(b.determinism.is_none());
Ok(())
}
#[test]
fn complexity_baseline_new_equals_default() -> Result<(), Box<dyn std::error::Error>> {
let a = ComplexityBaseline::new();
let b = ComplexityBaseline::default();
assert_eq!(a.baseline_version, b.baseline_version);
assert_eq!(a.generated_at, b.generated_at);
assert_eq!(a.files.len(), b.files.len());
Ok(())
}
#[test]
fn complexity_baseline_serde_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let b = ComplexityBaseline {
baseline_version: BASELINE_VERSION,
generated_at: "2025-01-01T00:00:00.000Z".into(),
commit: Some("abc123".into()),
metrics: BaselineMetrics::default(),
files: vec![FileBaselineEntry {
path: "src/lib.rs".into(),
code_lines: 100,
cyclomatic: 5,
cognitive: 3,
max_nesting: 2,
function_count: 10,
content_hash: Some("deadbeef".into()),
}],
complexity: None,
determinism: None,
};
let json = serde_json::to_string(&b)?;
let back: ComplexityBaseline = serde_json::from_str(&json)?;
assert_eq!(back.baseline_version, BASELINE_VERSION);
assert_eq!(back.commit.as_deref(), Some("abc123"));
assert_eq!(back.files.len(), 1);
assert_eq!(back.files[0].path, "src/lib.rs");
Ok(())
}
#[test]
fn timestamp_epoch() -> Result<(), Box<dyn std::error::Error>> {
let result = chrono_timestamp_iso8601(0);
assert_eq!(result, "1970-01-01T00:00:00.000Z");
Ok(())
}
#[test]
fn timestamp_with_millis() -> Result<(), Box<dyn std::error::Error>> {
let result = chrono_timestamp_iso8601(1735689600500);
assert!(result.ends_with(".500Z"));
assert!(result.starts_with("2025-01-01"));
Ok(())
}
proptest! {
#[test]
fn chrono_timestamp_matches_chrono(ms in 0u128..253_402_300_799_000u128) {
let chrono_dt = Utc
.timestamp_millis_opt(ms as i64)
.single()
.expect("timestamp within supported range");
let expected = chrono_dt.to_rfc3339_opts(SecondsFormat::Millis, true);
prop_assert_eq!(chrono_timestamp_iso8601(ms), expected);
}
#[test]
fn chrono_timestamp_is_rfc3339(ms in 0u128..253_402_300_799_000u128) {
let rendered = chrono_timestamp_iso8601(ms);
prop_assert!(chrono::DateTime::parse_from_rfc3339(&rendered).is_ok());
}
}
}