Skip to main content

vela_protocol/
review.rs

1//! Review import compatibility for frontier proof packets and legacy review bundles.
2
3use std::path::Path;
4
5use serde::Deserialize;
6
7use crate::bundle::ReviewEvent;
8use crate::events::StateEvent;
9use crate::project::Project;
10use crate::repo;
11
12#[derive(Debug)]
13pub struct ReviewImportReport {
14    pub source: String,
15    pub imported: usize,
16    pub new: usize,
17    pub duplicate: usize,
18    pub events_imported: usize,
19    pub events_new: usize,
20    pub events_duplicate: usize,
21}
22
23impl std::fmt::Display for ReviewImportReport {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(
26            f,
27            "Imported reviews from {}\n  {} review events imported ({} new, {} duplicate)\n  {} canonical events imported ({} new, {} duplicate)",
28            self.source,
29            self.imported,
30            self.new,
31            self.duplicate,
32            self.events_imported,
33            self.events_new,
34            self.events_duplicate,
35        )
36    }
37}
38
39#[derive(Debug, Deserialize)]
40struct PacketManifestHeader {
41    packet_format: String,
42}
43
44pub fn import_review_events(source: &Path, target: &Path) -> Result<ReviewImportReport, String> {
45    let review_result = load_review_events_from_path(source);
46    let state_result = load_state_events_from_path(source);
47    if review_result.is_err() && state_result.is_err() {
48        return Err(format!(
49            "Failed to import review or state events from {}: {}; {}",
50            source.display(),
51            review_result
52                .err()
53                .unwrap_or_else(|| "review parse failed".to_string()),
54            state_result
55                .err()
56                .unwrap_or_else(|| "state event parse failed".to_string())
57        ));
58    }
59    let review_events = review_result.unwrap_or_default();
60    let state_events = state_result.unwrap_or_default();
61    let mut frontier: Project =
62        repo::load_from_path(target).map_err(|e| format!("Failed to load target frontier: {e}"))?;
63
64    let existing_ids: std::collections::HashSet<String> = frontier
65        .review_events
66        .iter()
67        .map(|event| event.id.clone())
68        .collect();
69    let imported = review_events.len();
70    let mut new_count = 0usize;
71    let mut duplicate_count = 0usize;
72
73    for event in review_events {
74        if existing_ids.contains(&event.id) {
75            duplicate_count += 1;
76        } else {
77            frontier.review_events.push(event);
78            new_count += 1;
79        }
80    }
81
82    let existing_event_ids: std::collections::HashSet<String> = frontier
83        .events
84        .iter()
85        .map(|event| event.id.clone())
86        .collect();
87    let events_imported = state_events.len();
88    let mut events_new = 0usize;
89    let mut events_duplicate = 0usize;
90    for event in state_events {
91        if existing_event_ids.contains(&event.id)
92            || frontier.events.iter().any(|e| e.id == event.id)
93        {
94            events_duplicate += 1;
95        } else {
96            frontier.events.push(event);
97            events_new += 1;
98        }
99    }
100
101    crate::project::recompute_stats(&mut frontier);
102    repo::save_to_path(target, &frontier)
103        .map_err(|e| format!("Failed to save target frontier: {e}"))?;
104
105    Ok(ReviewImportReport {
106        source: source.display().to_string(),
107        imported,
108        new: new_count,
109        duplicate: duplicate_count,
110        events_imported,
111        events_new,
112        events_duplicate,
113    })
114}
115
116fn load_review_events_from_path(source: &Path) -> Result<Vec<ReviewEvent>, String> {
117    if is_packet_dir(source) {
118        return load_review_events_from_json_file(&source.join("reviews/review-events.json"));
119    }
120
121    if source.is_dir() {
122        let packet_style = source.join("review-events.json");
123        if packet_style.is_file() {
124            return load_review_events_from_json_file(&packet_style);
125        }
126        return Err(format!(
127            "Directory {} does not look like a packet or review-events bundle",
128            source.display()
129        ));
130    }
131
132    load_review_events_from_json_file(source)
133}
134
135fn load_state_events_from_path(source: &Path) -> Result<Vec<StateEvent>, String> {
136    if is_packet_dir(source) {
137        return load_state_events_from_json_file(&source.join("events/events.json"));
138    }
139    if source.is_dir() {
140        let event_bundle = source.join("events.json");
141        if event_bundle.is_file() {
142            return load_state_events_from_json_file(&event_bundle);
143        }
144        return Ok(Vec::new());
145    }
146    load_state_events_from_json_file(source)
147}
148
149fn load_state_events_from_json_file(path: &Path) -> Result<Vec<StateEvent>, String> {
150    let data = std::fs::read_to_string(path)
151        .map_err(|e| format!("Failed to read state events {}: {e}", path.display()))?;
152    if let Ok(events) = serde_json::from_str::<Vec<StateEvent>>(&data) {
153        return Ok(events);
154    }
155    let event = serde_json::from_str::<StateEvent>(&data)
156        .map_err(|e| format!("Failed to parse state event(s) {}: {e}", path.display()))?;
157    Ok(vec![event])
158}
159
160fn is_packet_dir(source: &Path) -> bool {
161    let manifest = source.join("manifest.json");
162    if !manifest.is_file() {
163        return false;
164    }
165    let Ok(content) = std::fs::read_to_string(&manifest) else {
166        return false;
167    };
168    let Ok(header) = serde_json::from_str::<PacketManifestHeader>(&content) else {
169        return false;
170    };
171    header.packet_format == "vela.frontier-packet"
172}
173
174fn load_review_events_from_json_file(path: &Path) -> Result<Vec<ReviewEvent>, String> {
175    let data = std::fs::read_to_string(path)
176        .map_err(|e| format!("Failed to read review events {}: {e}", path.display()))?;
177
178    if let Ok(events) = serde_json::from_str::<Vec<ReviewEvent>>(&data) {
179        return Ok(events);
180    }
181
182    let event = serde_json::from_str::<ReviewEvent>(&data)
183        .map_err(|e| format!("Failed to parse review event(s) {}: {e}", path.display()))?;
184    Ok(vec![event])
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::bundle::ReviewAction;
191
192    #[test]
193    fn import_review_events_from_json_file_merges_new_event() {
194        use tempfile::TempDir;
195
196        let tmp = TempDir::new().unwrap();
197        let target = tmp.path().join("target.json");
198        let frontier = crate::project::assemble("target", vec![], 0, 0, "target");
199        std::fs::write(&target, serde_json::to_string_pretty(&frontier).unwrap()).unwrap();
200
201        let review = ReviewEvent {
202            id: "rev_import_001".into(),
203            workspace: None,
204            finding_id: "vf_test".into(),
205            reviewer: "reviewer".into(),
206            reviewed_at: "2026-01-01T00:00:00Z".into(),
207            scope: None,
208            status: Some("accepted".into()),
209            action: ReviewAction::Approved,
210            reason: "looks right".into(),
211            evidence_considered: Vec::new(),
212            state_change: None,
213        };
214        let source = tmp.path().join("review.json");
215        std::fs::write(&source, serde_json::to_string_pretty(&review).unwrap()).unwrap();
216
217        let report = import_review_events(&source, &target).unwrap();
218        assert_eq!(report.imported, 1);
219        assert_eq!(report.new, 1);
220        assert_eq!(report.duplicate, 0);
221
222        let loaded = crate::repo::load_from_path(&target).unwrap();
223        assert_eq!(loaded.review_events.len(), 1);
224        assert_eq!(loaded.review_events[0].id, "rev_import_001");
225    }
226
227    #[test]
228    fn import_review_events_from_packet_dir_reads_review_events_bundle() {
229        use tempfile::TempDir;
230
231        let tmp = TempDir::new().unwrap();
232        let packet_dir = tmp.path().join("packet");
233        std::fs::create_dir_all(packet_dir.join("reviews")).unwrap();
234        std::fs::write(
235            packet_dir.join("manifest.json"),
236            r#"{"packet_format":"vela.frontier-packet"}"#,
237        )
238        .unwrap();
239
240        let review = ReviewEvent {
241            id: "rev_packet_ingest_001".into(),
242            workspace: Some("packet".into()),
243            finding_id: "vf_packet".into(),
244            reviewer: "external-reviewer".into(),
245            reviewed_at: "2026-01-01T00:00:00Z".into(),
246            scope: Some("bbb".into()),
247            status: Some("accepted".into()),
248            action: ReviewAction::Qualified {
249                target: "trusted_interpretation".into(),
250            },
251            reason: "narrow this claim".into(),
252            evidence_considered: Vec::new(),
253            state_change: None,
254        };
255        std::fs::write(
256            packet_dir.join("reviews/review-events.json"),
257            serde_json::to_string_pretty(&vec![review]).unwrap(),
258        )
259        .unwrap();
260
261        let target = tmp.path().join("target.json");
262        let frontier = crate::project::assemble("target", vec![], 0, 0, "target");
263        std::fs::write(&target, serde_json::to_string_pretty(&frontier).unwrap()).unwrap();
264
265        let report = import_review_events(&packet_dir, &target).unwrap();
266        assert_eq!(report.imported, 1);
267        assert_eq!(report.new, 1);
268
269        let loaded = crate::repo::load_from_path(&target).unwrap();
270        assert_eq!(loaded.review_events.len(), 1);
271        assert_eq!(loaded.review_events[0].id, "rev_packet_ingest_001");
272    }
273}