visual-rubric 0.2.0

AI-assisted screenshot rubric runner for local visual UX review
Documentation
use std::borrow::Cow;

use super::{
    AggregateStatus, AssetRubricReport, AssetRubricResult, IssueClassificationInput,
    IssueClassifier, IssueRecommendation,
};

pub(super) fn aggregate_status(assets: &[AssetRubricReport]) -> AggregateStatus {
    if assets.iter().any(|asset| {
        matches!(
            asset.result,
            AssetRubricResult::Error { .. } | AssetRubricResult::NotEvaluatedAfterError { .. }
        )
    }) {
        AggregateStatus::Error
    } else if assets
        .iter()
        .any(|asset| matches!(asset.result, AssetRubricResult::Fail { .. }))
    {
        AggregateStatus::Fail
    } else if assets
        .iter()
        .all(|asset| matches!(asset.result, AssetRubricResult::Skipped { .. }))
    {
        AggregateStatus::Skipped
    } else {
        AggregateStatus::Pass
    }
}

pub(super) fn classify_recommendations(
    classifier: Option<&dyn IssueClassifier>,
    assets: &[AssetRubricReport],
) -> Vec<IssueRecommendation> {
    let Some(classifier) = classifier else {
        return Vec::new();
    };
    let mut recommendations = Vec::with_capacity(assets.len());
    for asset in assets {
        let Some(issue_text) = issue_text(&asset.result) else {
            continue;
        };
        for recommendation in classifier.classify(IssueClassificationInput {
            asset,
            issue_text: &issue_text,
        }) {
            merge_recommendation(&mut recommendations, recommendation);
        }
    }
    recommendations.sort_by(|left, right| {
        right
            .severity
            .cmp(&left.severity)
            .then_with(|| left.id.cmp(&right.id))
    });
    recommendations
}

fn issue_text(result: &AssetRubricResult) -> Option<Cow<'_, str>> {
    match result {
        AssetRubricResult::Fail { reason, anomalies } => {
            let anomaly_len: usize = anomalies.iter().map(String::len).sum();
            let mut text = String::with_capacity(reason.len() + anomaly_len + anomalies.len());
            text.push_str(reason);
            for anomaly in anomalies {
                text.push(' ');
                text.push_str(anomaly);
            }
            Some(Cow::Owned(text))
        }
        AssetRubricResult::Error { message } => Some(Cow::Borrowed(message)),
        AssetRubricResult::NotEvaluatedAfterError {
            root_error,
            retry_hint,
        } => Some(Cow::Owned(format!("{root_error} {retry_hint}"))),
        AssetRubricResult::Pass { .. } | AssetRubricResult::Skipped { .. } => None,
    }
}

fn merge_recommendation(
    recommendations: &mut Vec<IssueRecommendation>,
    incoming: IssueRecommendation,
) {
    let existing = recommendations.iter_mut().find(|candidate| {
        candidate.id == incoming.id
            && candidate.class == incoming.class
            && candidate.suggested_fix == incoming.suggested_fix
            && candidate.candidate_modules == incoming.candidate_modules
    });
    let Some(existing) = existing else {
        recommendations.push(incoming);
        return;
    };
    existing.severity = existing.severity.max(incoming.severity);
    for asset in incoming.affected_assets {
        if !existing.affected_assets.contains(&asset) {
            existing.affected_assets.push(asset);
        }
    }
    existing.affected_assets.sort();
    for evidence in incoming.evidence {
        if !existing.evidence.contains(&evidence) && existing.evidence.len() < 5 {
            existing.evidence.push(evidence);
        }
    }
}