vela-protocol 0.113.0

Core library for the Vela scientific knowledge protocol: replayable frontier state, signed canonical events, and proof packets.
Documentation
//! Review import compatibility for frontier proof packets and legacy review bundles.

use std::path::Path;

use serde::Deserialize;

use crate::bundle::ReviewEvent;
use crate::events::StateEvent;
use crate::project::Project;
use crate::repo;

#[derive(Debug)]
pub struct ReviewImportReport {
    pub source: String,
    pub imported: usize,
    pub new: usize,
    pub duplicate: usize,
    pub events_imported: usize,
    pub events_new: usize,
    pub events_duplicate: usize,
}

impl std::fmt::Display for ReviewImportReport {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Imported reviews from {}\n  {} review events imported ({} new, {} duplicate)\n  {} canonical events imported ({} new, {} duplicate)",
            self.source,
            self.imported,
            self.new,
            self.duplicate,
            self.events_imported,
            self.events_new,
            self.events_duplicate,
        )
    }
}

#[derive(Debug, Deserialize)]
struct PacketManifestHeader {
    packet_format: String,
}

pub fn import_review_events(source: &Path, target: &Path) -> Result<ReviewImportReport, String> {
    let review_result = load_review_events_from_path(source);
    let state_result = load_state_events_from_path(source);
    if review_result.is_err() && state_result.is_err() {
        return Err(format!(
            "Failed to import review or state events from {}: {}; {}",
            source.display(),
            review_result
                .err()
                .unwrap_or_else(|| "review parse failed".to_string()),
            state_result
                .err()
                .unwrap_or_else(|| "state event parse failed".to_string())
        ));
    }
    let review_events = review_result.unwrap_or_default();
    let state_events = state_result.unwrap_or_default();
    let mut frontier: Project =
        repo::load_from_path(target).map_err(|e| format!("Failed to load target frontier: {e}"))?;

    let existing_ids: std::collections::HashSet<String> = frontier
        .review_events
        .iter()
        .map(|event| event.id.clone())
        .collect();
    let imported = review_events.len();
    let mut new_count = 0usize;
    let mut duplicate_count = 0usize;

    for event in review_events {
        if existing_ids.contains(&event.id) {
            duplicate_count += 1;
        } else {
            frontier.review_events.push(event);
            new_count += 1;
        }
    }

    let existing_event_ids: std::collections::HashSet<String> = frontier
        .events
        .iter()
        .map(|event| event.id.clone())
        .collect();
    let events_imported = state_events.len();
    let mut events_new = 0usize;
    let mut events_duplicate = 0usize;
    for event in state_events {
        if existing_event_ids.contains(&event.id)
            || frontier.events.iter().any(|e| e.id == event.id)
        {
            events_duplicate += 1;
        } else {
            frontier.events.push(event);
            events_new += 1;
        }
    }

    crate::project::recompute_stats(&mut frontier);
    repo::save_to_path(target, &frontier)
        .map_err(|e| format!("Failed to save target frontier: {e}"))?;

    Ok(ReviewImportReport {
        source: source.display().to_string(),
        imported,
        new: new_count,
        duplicate: duplicate_count,
        events_imported,
        events_new,
        events_duplicate,
    })
}

fn load_review_events_from_path(source: &Path) -> Result<Vec<ReviewEvent>, String> {
    if is_packet_dir(source) {
        return load_review_events_from_json_file(&source.join("reviews/review-events.json"));
    }

    if source.is_dir() {
        let packet_style = source.join("review-events.json");
        if packet_style.is_file() {
            return load_review_events_from_json_file(&packet_style);
        }
        return Err(format!(
            "Directory {} does not look like a packet or review-events bundle",
            source.display()
        ));
    }

    load_review_events_from_json_file(source)
}

fn load_state_events_from_path(source: &Path) -> Result<Vec<StateEvent>, String> {
    if is_packet_dir(source) {
        return load_state_events_from_json_file(&source.join("events/events.json"));
    }
    if source.is_dir() {
        let event_bundle = source.join("events.json");
        if event_bundle.is_file() {
            return load_state_events_from_json_file(&event_bundle);
        }
        return Ok(Vec::new());
    }
    load_state_events_from_json_file(source)
}

fn load_state_events_from_json_file(path: &Path) -> Result<Vec<StateEvent>, String> {
    let data = std::fs::read_to_string(path)
        .map_err(|e| format!("Failed to read state events {}: {e}", path.display()))?;
    if let Ok(events) = serde_json::from_str::<Vec<StateEvent>>(&data) {
        return Ok(events);
    }
    let event = serde_json::from_str::<StateEvent>(&data)
        .map_err(|e| format!("Failed to parse state event(s) {}: {e}", path.display()))?;
    Ok(vec![event])
}

fn is_packet_dir(source: &Path) -> bool {
    let manifest = source.join("manifest.json");
    if !manifest.is_file() {
        return false;
    }
    let Ok(content) = std::fs::read_to_string(&manifest) else {
        return false;
    };
    let Ok(header) = serde_json::from_str::<PacketManifestHeader>(&content) else {
        return false;
    };
    header.packet_format == "vela.frontier-packet"
}

fn load_review_events_from_json_file(path: &Path) -> Result<Vec<ReviewEvent>, String> {
    let data = std::fs::read_to_string(path)
        .map_err(|e| format!("Failed to read review events {}: {e}", path.display()))?;

    if let Ok(events) = serde_json::from_str::<Vec<ReviewEvent>>(&data) {
        return Ok(events);
    }

    let event = serde_json::from_str::<ReviewEvent>(&data)
        .map_err(|e| format!("Failed to parse review event(s) {}: {e}", path.display()))?;
    Ok(vec![event])
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bundle::ReviewAction;

    #[test]
    fn import_review_events_from_json_file_merges_new_event() {
        use tempfile::TempDir;

        let tmp = TempDir::new().unwrap();
        let target = tmp.path().join("target.json");
        let frontier = crate::project::assemble("target", vec![], 0, 0, "target");
        std::fs::write(&target, serde_json::to_string_pretty(&frontier).unwrap()).unwrap();

        let review = ReviewEvent {
            id: "rev_import_001".into(),
            workspace: None,
            finding_id: "vf_test".into(),
            reviewer: "reviewer".into(),
            reviewed_at: "2026-01-01T00:00:00Z".into(),
            scope: None,
            status: Some("accepted".into()),
            action: ReviewAction::Approved,
            reason: "looks right".into(),
            evidence_considered: Vec::new(),
            state_change: None,
        };
        let source = tmp.path().join("review.json");
        std::fs::write(&source, serde_json::to_string_pretty(&review).unwrap()).unwrap();

        let report = import_review_events(&source, &target).unwrap();
        assert_eq!(report.imported, 1);
        assert_eq!(report.new, 1);
        assert_eq!(report.duplicate, 0);

        let loaded = crate::repo::load_from_path(&target).unwrap();
        assert_eq!(loaded.review_events.len(), 1);
        assert_eq!(loaded.review_events[0].id, "rev_import_001");
    }

    #[test]
    fn import_review_events_from_packet_dir_reads_review_events_bundle() {
        use tempfile::TempDir;

        let tmp = TempDir::new().unwrap();
        let packet_dir = tmp.path().join("packet");
        std::fs::create_dir_all(packet_dir.join("reviews")).unwrap();
        std::fs::write(
            packet_dir.join("manifest.json"),
            r#"{"packet_format":"vela.frontier-packet"}"#,
        )
        .unwrap();

        let review = ReviewEvent {
            id: "rev_packet_ingest_001".into(),
            workspace: Some("packet".into()),
            finding_id: "vf_packet".into(),
            reviewer: "external-reviewer".into(),
            reviewed_at: "2026-01-01T00:00:00Z".into(),
            scope: Some("bbb".into()),
            status: Some("accepted".into()),
            action: ReviewAction::Qualified {
                target: "trusted_interpretation".into(),
            },
            reason: "narrow this claim".into(),
            evidence_considered: Vec::new(),
            state_change: None,
        };
        std::fs::write(
            packet_dir.join("reviews/review-events.json"),
            serde_json::to_string_pretty(&vec![review]).unwrap(),
        )
        .unwrap();

        let target = tmp.path().join("target.json");
        let frontier = crate::project::assemble("target", vec![], 0, 0, "target");
        std::fs::write(&target, serde_json::to_string_pretty(&frontier).unwrap()).unwrap();

        let report = import_review_events(&packet_dir, &target).unwrap();
        assert_eq!(report.imported, 1);
        assert_eq!(report.new, 1);

        let loaded = crate::repo::load_from_path(&target).unwrap();
        assert_eq!(loaded.review_events.len(), 1);
        assert_eq!(loaded.review_events[0].id, "rev_packet_ingest_001");
    }
}