git-perf 0.22.0

Track, plot, and statistically validate simple measurements using git-notes for storage
Documentation
use crate::{
    data::{Commit, CommitSummary, MeasurementData, MeasurementSummary},
    git::git_interop::{self},
    stats::{NumericReductionFunc, ReductionFunc},
};

use anyhow::Result;

pub trait MeasurementReducer<'a>: Iterator<Item = &'a MeasurementData> {
    fn reduce_by(self, fun: ReductionFunc) -> Option<MeasurementSummary>;
}

pub fn summarize_measurements<'a, F>(
    commits: impl Iterator<Item = Result<Commit>> + 'a,
    summarize_by: &'a ReductionFunc,
    filter_by: &'a F,
) -> impl Iterator<Item = Result<CommitSummary>> + 'a
where
    F: Fn(&MeasurementData) -> bool,
{
    commits.map(move |c| {
        c.map(|c| {
            let measurement = c
                .measurements
                .iter()
                .filter(|m| filter_by(m))
                .reduce_by(*summarize_by);

            CommitSummary {
                commit: c.commit,
                measurement,
            }
        })
    })
}

/// Adapter to take results while the epoch is the same as the first one encountered.
pub fn take_while_same_epoch<I>(iter: I) -> impl Iterator<Item = Result<CommitSummary>>
where
    I: Iterator<Item = Result<CommitSummary>>,
{
    let mut first_epoch: Option<u32> = None;
    iter.take_while(move |m| match m {
        Ok(CommitSummary {
            measurement: Some(m),
            ..
        }) => {
            let prev_epoch = first_epoch;
            first_epoch = Some(m.epoch);
            prev_epoch.unwrap_or(m.epoch) == m.epoch
        }
        _ => true,
    })
}

/// Collects aggregated measurement values and commit SHAs for the current epoch.
///
/// Returns `(values, commit_shas)` ordered from newest (HEAD) to oldest.
pub fn collect_epoch_measurements<F>(
    commits: impl Iterator<Item = Result<Commit>>,
    filter_by: F,
    summarize_by: &ReductionFunc,
) -> (Vec<f64>, Vec<String>)
where
    F: Fn(&MeasurementData) -> bool,
{
    let epoch_data: Vec<(String, f64)> =
        take_while_same_epoch(summarize_measurements(commits, summarize_by, &filter_by))
            .filter_map(|r| r.ok())
            .filter_map(|cs| cs.measurement.map(|m| (cs.commit, m.val)))
            .collect();

    let values = epoch_data.iter().map(|(_, v)| *v).collect();
    let shas = epoch_data.iter().map(|(c, _)| c.clone()).collect();
    (values, shas)
}

impl<'a, T> MeasurementReducer<'a> for T
where
    T: Iterator<Item = &'a MeasurementData>,
{
    fn reduce_by(self, fun: ReductionFunc) -> Option<MeasurementSummary> {
        let mut peekable = self.peekable();
        let expected_epoch = peekable.peek().map(|m| m.epoch);
        let mut vals = peekable.map(|m| {
            debug_assert_eq!(Some(m.epoch), expected_epoch);
            m.val
        });

        let aggregate_val = vals.aggregate_by(fun);

        Some(MeasurementSummary {
            epoch: expected_epoch?,
            val: aggregate_val?,
        })
    }
}

/// Walks through commit history starting from a specific commit, retrieving performance measurements.
///
/// This function traverses the Git commit history beginning at the specified commit
/// and returns an iterator of commits with their associated performance measurements
/// deserialized from git notes. The iterator yields up to `num_commits` commits,
/// following the first-parent ancestry chain.
///
/// # Arguments
///
/// * `start_commit` - The committish reference to start walking from (e.g., "HEAD", "main", commit hash)
/// * `num_commits` - Maximum number of commits to retrieve
///
/// # Returns
///
/// Returns an iterator that yields `Result<Commit>` for each commit in the history.
/// Each successful `Commit` contains the commit hash and its deserialized performance measurements.
///
/// # Errors
///
/// Returns an error if:
/// - The starting commit cannot be resolved
/// - The repository is a shallow clone (full history required)
/// - Git operations fail during commit traversal
///
/// # Notes
///
/// Measurements are copied during deserialization. This is necessary due to the current
/// storage model but could be optimized with architectural changes.
///
/// # Examples
///
/// ```no_run
/// # use git_perf::measurement_retrieval::walk_commits_from;
/// for commit_result in walk_commits_from("HEAD", 10, None, None).unwrap() {
///     let commit = commit_result.unwrap();
///     println!("Commit: {}", commit.commit);
/// }
/// ```
#[cfg(test)]
mod tests {
    use super::*;
    use crate::data::{Commit, MeasurementData};
    use crate::stats::ReductionFunc;
    use anyhow::Result;

    fn make_commit_with_measurement(sha: &str, name: &str, val: f64) -> Commit {
        Commit {
            commit: sha.to_string(),
            title: String::new(),
            author: String::new(),
            measurements: vec![MeasurementData {
                epoch: 1,
                name: name.to_string(),
                timestamp: 0.0,
                val,
                key_values: std::collections::HashMap::new(),
            }],
        }
    }

    fn make_commits_iter(commits: &[Result<Commit>]) -> impl Iterator<Item = Result<Commit>> + '_ {
        commits.iter().map(|r| match r {
            Ok(c) => Ok(c.clone()),
            Err(e) => Err(anyhow::anyhow!("{}", e)),
        })
    }

    #[test]
    fn test_collect_epoch_measurements_basic() {
        // 5 commits with values 10.0..50.0, same epoch
        let commits: Vec<Result<Commit>> = (0..5)
            .map(|i| {
                Ok(make_commit_with_measurement(
                    &format!("sha{:040}", i),
                    "bench",
                    (i as f64 + 1.0) * 10.0,
                ))
            })
            .collect();

        let (values, shas) = collect_epoch_measurements(
            make_commits_iter(&commits),
            |m: &MeasurementData| m.name == "bench",
            &ReductionFunc::Min,
        );
        assert_eq!(values.len(), 5);
        assert_eq!(shas.len(), 5);
        assert!((values[0] - 10.0).abs() < f64::EPSILON); // HEAD is newest (index 0)
    }

    #[test]
    fn test_collect_epoch_measurements_filters_by_name() {
        let commits: Vec<Result<Commit>> =
            vec![Ok(make_commit_with_measurement("sha1", "other", 99.0))];
        let (values, shas) = collect_epoch_measurements(
            make_commits_iter(&commits),
            |m: &MeasurementData| m.name == "bench",
            &ReductionFunc::Min,
        );
        assert!(values.is_empty());
        assert!(shas.is_empty());
    }

    #[test]
    fn test_collect_epoch_measurements_stops_at_epoch_change() {
        let mut commits: Vec<Result<Commit>> = Vec::new();
        // First 3 commits: epoch 1
        for i in 0..3 {
            commits.push(Ok(Commit {
                commit: format!("sha{}", i),
                title: String::new(),
                author: String::new(),
                measurements: vec![MeasurementData {
                    epoch: 1,
                    name: "bench".to_string(),
                    timestamp: 0.0,
                    val: 10.0,
                    key_values: std::collections::HashMap::new(),
                }],
            }));
        }
        // Next 2 commits: epoch 2 (older, different epoch)
        for i in 3..5 {
            commits.push(Ok(Commit {
                commit: format!("sha{}", i),
                title: String::new(),
                author: String::new(),
                measurements: vec![MeasurementData {
                    epoch: 2,
                    name: "bench".to_string(),
                    timestamp: 0.0,
                    val: 20.0,
                    key_values: std::collections::HashMap::new(),
                }],
            }));
        }

        let (values, _) = collect_epoch_measurements(
            make_commits_iter(&commits),
            |m: &MeasurementData| m.name == "bench",
            &ReductionFunc::Min,
        );
        // Should only return epoch 1 data (3 commits)
        assert_eq!(values.len(), 3);
        for v in &values {
            assert!(
                (v - 10.0).abs() < f64::EPSILON,
                "Should only return epoch 1 values"
            );
        }
    }
}

pub fn walk_commits_from(
    start_commit: &str,
    num_commits: usize,
    since: Option<&str>,
    until: Option<&str>,
) -> Result<impl Iterator<Item = Result<Commit>>> {
    let vec = git_interop::walk_commits_from(start_commit, num_commits, since, until)?;
    Ok(vec
        .into_iter()
        .take(num_commits)
        .map(|commit_data| -> Result<Commit> {
            let measurements =
                crate::serialization::deserialize(&commit_data.note_lines.join("\n"));
            Ok(Commit {
                commit: commit_data.sha,
                title: commit_data.title,
                author: commit_data.author,
                measurements,
            })
        }))
    // When this fails it is due to a shallow clone.
}