1use 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}