sourceright 0.1.12

Reference verification infrastructure for academic and legal citation workflows.
Documentation
use serde::{Deserialize, Serialize};

use crate::sidecar::{
    ReviewDecision, ReviewQueueEntry, ReviewStatus, ReviewStatusTransitionError,
    VerificationSidecar,
};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReviewPartition {
    pub id: String,
    pub entries: Vec<ReviewQueueEntry>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewDecisionImport {
    pub reference_id: String,
    pub decision: String,
    pub reviewer: String,
    pub decided_at: String,
    pub status: ReviewStatus,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewImportReport {
    pub applied: usize,
    pub errors: Vec<ReviewImportError>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewImportError {
    pub reference_id: String,
    pub message: String,
}

pub fn partition_review_queue(
    sidecar: &VerificationSidecar,
    max_entries: usize,
) -> Vec<ReviewPartition> {
    let max_entries = max_entries.max(1);
    sidecar
        .review_queue_entries()
        .chunks(max_entries)
        .enumerate()
        .map(|(index, entries)| ReviewPartition {
            id: format!("review-partition-{:03}", index + 1),
            entries: entries.to_vec(),
        })
        .collect()
}

pub fn apply_review_decisions(
    sidecar: &mut VerificationSidecar,
    decisions: &[ReviewDecisionImport],
) -> ReviewImportReport {
    let mut applied = 0;
    let mut errors = Vec::new();

    for decision in decisions {
        let Some(verification) = sidecar.references.get_mut(&decision.reference_id) else {
            errors.push(ReviewImportError {
                reference_id: decision.reference_id.clone(),
                message: "No verification sidecar entry exists for this reference id.".to_string(),
            });
            continue;
        };

        let review_decision = ReviewDecision {
            decision: decision.decision.clone(),
            reviewer: decision.reviewer.clone(),
            decided_at: decision.decided_at.clone(),
            notes: decision.notes.clone(),
        };

        match verification.record_review_decision(review_decision, decision.status.clone()) {
            Ok(()) => applied += 1,
            Err(error) => errors.push(import_error(&decision.reference_id, error)),
        }
    }

    ReviewImportReport { applied, errors }
}

fn import_error(reference_id: &str, error: ReviewStatusTransitionError) -> ReviewImportError {
    ReviewImportError {
        reference_id: reference_id.to_string(),
        message: format!(
            "Invalid review status transition from {:?} to {:?}.",
            error.from, error.to
        ),
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use super::*;
    use crate::sidecar::{ReferenceVerification, VerificationSidecar};

    #[test]
    fn review_queue_partitions_are_stable_and_bounded() {
        let mut sidecar = VerificationSidecar::empty();
        for id in ["a", "b", "c"] {
            sidecar.references.insert(
                id.to_string(),
                ReferenceVerification {
                    review_status: ReviewStatus::Queued,
                    ..ReferenceVerification::default()
                },
            );
        }

        let partitions = partition_review_queue(&sidecar, 2);

        assert_eq!(partitions.len(), 2);
        assert_eq!(partitions[0].id, "review-partition-001");
        assert_eq!(partitions[0].entries.len(), 2);
        assert_eq!(partitions[1].entries.len(), 1);
    }

    #[test]
    fn review_decision_import_records_decisions_and_status() {
        let mut sidecar = VerificationSidecar {
            schema_version: crate::sidecar::SIDECAR_SCHEMA_VERSION.to_string(),
            references: BTreeMap::from([(
                "smith-2024".to_string(),
                ReferenceVerification {
                    review_status: ReviewStatus::Queued,
                    ..ReferenceVerification::default()
                },
            )]),
        };

        let report = apply_review_decisions(
            &mut sidecar,
            &[ReviewDecisionImport {
                reference_id: "smith-2024".to_string(),
                decision: "accepted provider DOI".to_string(),
                reviewer: "agent:reviewer-1".to_string(),
                decided_at: "2026-05-10T00:00:00Z".to_string(),
                status: ReviewStatus::Resolved,
                notes: Some("Matches publisher landing page.".to_string()),
            }],
        );

        assert_eq!(report.applied, 1);
        assert!(report.errors.is_empty());
        assert_eq!(
            sidecar.references["smith-2024"].review_status,
            ReviewStatus::Resolved
        );
        assert_eq!(
            sidecar.references["smith-2024"].review_decisions[0].decision,
            "accepted provider DOI"
        );
    }

    #[test]
    fn review_decision_import_reports_unknown_references() {
        let mut sidecar = VerificationSidecar::empty();

        let report = apply_review_decisions(
            &mut sidecar,
            &[ReviewDecisionImport {
                reference_id: "missing".to_string(),
                decision: "unresolved".to_string(),
                reviewer: "agent:reviewer-1".to_string(),
                decided_at: "2026-05-10T00:00:00Z".to_string(),
                status: ReviewStatus::Unresolved,
                notes: None,
            }],
        );

        assert_eq!(report.applied, 0);
        assert_eq!(report.errors[0].reference_id, "missing");
    }
}