1use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::bundle::{ConfidenceUpdate, FindingBundle, Link, ReviewEvent};
10use crate::events::StateEvent;
11use crate::project::{self, Project};
12use crate::proposals::{ProofState, StateProposal};
13
14#[derive(Debug, Clone, PartialEq)]
18pub enum VelaSource {
19 ProjectFile(PathBuf),
21 VelaRepo(PathBuf),
23 PacketDir(PathBuf),
25}
26
27#[derive(Debug, Deserialize)]
28struct PacketManifestHeader {
29 packet_format: String,
30 #[serde(default)]
31 source: Option<PacketSourceHeader>,
32}
33
34#[derive(Debug, Default, Deserialize)]
35struct PacketSourceHeader {
36 #[serde(default)]
37 project_name: String,
38 #[serde(default)]
39 description: String,
40 #[serde(default)]
41 compiled_at: String,
42 #[serde(default)]
43 compiler: String,
44 #[serde(default)]
45 vela_version: String,
46 #[serde(default)]
47 schema: String,
48}
49
50#[derive(Debug, Default, Deserialize)]
51struct PacketOverviewHeader {
52 #[serde(default)]
53 project_name: String,
54 #[serde(default)]
55 description: String,
56 #[serde(default)]
57 compiled_at: String,
58 #[serde(default)]
59 papers_processed: usize,
60}
61
62pub fn detect(path: &Path) -> Result<VelaSource, String> {
68 if path.is_file() {
69 return Ok(VelaSource::ProjectFile(path.to_path_buf()));
70 }
71 if path.is_dir() {
72 if is_packet_dir(path) {
73 return Ok(VelaSource::PacketDir(path.to_path_buf()));
74 }
75 let vela_dir = path.join(".vela");
76 if vela_dir.is_dir() {
77 return Ok(VelaSource::VelaRepo(path.to_path_buf()));
78 }
79 if path.extension().is_some_and(|ext| ext == "json") {
81 return Ok(VelaSource::ProjectFile(path.to_path_buf()));
82 }
83 return Err(format!(
84 "Directory '{}' is not a Vela repository or frontier packet. Run `vela init`, `vela import`, or `vela migrate` first.",
85 path.display()
86 ));
87 }
88 if path.extension().is_some_and(|ext| ext == "json") {
90 return Ok(VelaSource::ProjectFile(path.to_path_buf()));
91 }
92 Err(format!(
93 "Path '{}' does not exist. Provide a .json file, frontier packet, or a directory with .vela/",
94 path.display()
95 ))
96}
97
98#[derive(Debug, Serialize, Deserialize)]
101struct RepoConfig {
102 project: RepoProjectMeta,
103}
104
105#[derive(Debug, Serialize, Deserialize)]
106struct RepoProjectMeta {
107 name: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 frontier_id: Option<String>,
110 #[serde(default, skip_serializing_if = "String::is_empty")]
111 compiled_at: String,
112 #[serde(default)]
113 description: String,
114 #[serde(default = "default_compiler")]
115 compiler: String,
116 #[serde(default)]
117 papers_processed: usize,
118}
119
120fn default_compiler() -> String {
121 crate::project::VELA_COMPILER_VERSION.into()
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
129struct ManifestLink {
130 source: String,
131 target: String,
132 #[serde(rename = "type")]
133 link_type: String,
134 #[serde(default)]
135 note: String,
136 #[serde(default = "default_inferred_by")]
137 inferred_by: String,
138 #[serde(default)]
139 created_at: String,
140}
141
142fn default_inferred_by() -> String {
143 "compiler".into()
144}
145
146pub fn load(source: &VelaSource) -> Result<Project, String> {
150 match source {
151 VelaSource::ProjectFile(path) => load_project_file(path),
152 VelaSource::VelaRepo(dir) => load_vela_repo(dir),
153 VelaSource::PacketDir(dir) => load_packet_dir(dir),
154 }
155}
156
157pub(crate) fn load_project_file(path: &Path) -> Result<Project, String> {
158 let data = std::fs::read_to_string(path)
159 .map_err(|e| format!("Failed to read project file '{}': {e}", path.display()))?;
160 serde_json::from_str(&data)
161 .map_err(|e| format!("Failed to parse project JSON '{}': {e}", path.display()))
162}
163
164fn load_packet_dir(dir: &Path) -> Result<Project, String> {
165 let manifest_path = dir.join("manifest.json");
166 let manifest_data = std::fs::read_to_string(&manifest_path).map_err(|e| {
167 format!(
168 "Failed to read packet manifest '{}': {e}",
169 manifest_path.display()
170 )
171 })?;
172 let manifest: PacketManifestHeader = serde_json::from_str(&manifest_data).map_err(|e| {
173 format!(
174 "Failed to parse packet manifest '{}': {e}",
175 manifest_path.display()
176 )
177 })?;
178
179 if manifest.packet_format != "vela.frontier-packet" {
180 return Err(format!(
181 "Unsupported packet format '{}' in {}",
182 manifest.packet_format,
183 manifest_path.display()
184 ));
185 }
186
187 let findings_path = dir.join("findings/full.json");
188 let findings_data = std::fs::read_to_string(&findings_path).map_err(|e| {
189 format!(
190 "Failed to read packet findings '{}': {e}",
191 findings_path.display()
192 )
193 })?;
194 let findings: Vec<FindingBundle> = serde_json::from_str(&findings_data).map_err(|e| {
195 format!(
196 "Failed to parse packet findings '{}': {e}",
197 findings_path.display()
198 )
199 })?;
200
201 let reviews_path = dir.join("reviews/review-events.json");
202 let review_events: Vec<ReviewEvent> = if reviews_path.is_file() {
203 let reviews_data = std::fs::read_to_string(&reviews_path).map_err(|e| {
204 format!(
205 "Failed to read packet reviews '{}': {e}",
206 reviews_path.display()
207 )
208 })?;
209 serde_json::from_str(&reviews_data).map_err(|e| {
210 format!(
211 "Failed to parse packet reviews '{}': {e}",
212 reviews_path.display()
213 )
214 })?
215 } else {
216 Vec::new()
217 };
218 let confidence_updates_path = dir.join("reviews/confidence-updates.json");
219 let confidence_updates: Vec<ConfidenceUpdate> = if confidence_updates_path.is_file() {
220 let updates_data = std::fs::read_to_string(&confidence_updates_path).map_err(|e| {
221 format!(
222 "Failed to read packet confidence updates '{}': {e}",
223 confidence_updates_path.display()
224 )
225 })?;
226 serde_json::from_str(&updates_data).map_err(|e| {
227 format!(
228 "Failed to parse packet confidence updates '{}': {e}",
229 confidence_updates_path.display()
230 )
231 })?
232 } else {
233 Vec::new()
234 };
235 let events_path = dir.join("events/events.json");
236 let events: Vec<StateEvent> = if events_path.is_file() {
237 let events_data = std::fs::read_to_string(&events_path).map_err(|e| {
238 format!(
239 "Failed to read packet events '{}': {e}",
240 events_path.display()
241 )
242 })?;
243 serde_json::from_str(&events_data).map_err(|e| {
244 format!(
245 "Failed to parse packet events '{}': {e}",
246 events_path.display()
247 )
248 })?
249 } else {
250 Vec::new()
251 };
252 let proposals_path = dir.join("proposals/proposals.json");
253 let proposals: Vec<StateProposal> = if proposals_path.is_file() {
254 let proposals_data = std::fs::read_to_string(&proposals_path).map_err(|e| {
255 format!(
256 "Failed to read packet proposals '{}': {e}",
257 proposals_path.display()
258 )
259 })?;
260 serde_json::from_str(&proposals_data).map_err(|e| {
261 format!(
262 "Failed to parse packet proposals '{}': {e}",
263 proposals_path.display()
264 )
265 })?
266 } else {
267 Vec::new()
268 };
269
270 let overview_path = dir.join("overview.json");
271 let overview: PacketOverviewHeader = if overview_path.is_file() {
272 let overview_data = std::fs::read_to_string(&overview_path).map_err(|e| {
273 format!(
274 "Failed to read packet overview '{}': {e}",
275 overview_path.display()
276 )
277 })?;
278 serde_json::from_str(&overview_data).map_err(|e| {
279 format!(
280 "Failed to parse packet overview '{}': {e}",
281 overview_path.display()
282 )
283 })?
284 } else {
285 PacketOverviewHeader::default()
286 };
287
288 let source = manifest.source.unwrap_or_default();
289 let name = first_non_empty([
290 source.project_name.as_str(),
291 overview.project_name.as_str(),
292 dir.file_name()
293 .and_then(|name| name.to_str())
294 .unwrap_or("packet"),
295 ]);
296 let description = first_non_empty([
297 source.description.as_str(),
298 overview.description.as_str(),
299 "",
300 ]);
301 let compiled_at = first_non_empty([
302 source.compiled_at.as_str(),
303 overview.compiled_at.as_str(),
304 "",
305 ]);
306
307 let mut project = project::assemble(name, findings, overview.papers_processed, 0, description);
308 if !compiled_at.is_empty() {
309 project.project.compiled_at = compiled_at.to_string();
310 }
311 if !source.compiler.is_empty() {
312 project.project.compiler = source.compiler;
313 }
314 if !source.vela_version.is_empty() {
315 project.vela_version = source.vela_version;
316 }
317 if !source.schema.is_empty() {
318 project.schema = source.schema;
319 }
320 project.review_events = review_events;
321 project.confidence_updates = confidence_updates;
322 project.events = events;
323 project.proposals = proposals;
324 project::recompute_stats(&mut project);
325 Ok(project)
326}
327
328fn load_vela_repo(dir: &Path) -> Result<Project, String> {
329 let vela_dir = dir.join(".vela");
330 let config_path = vela_dir.join("config.toml");
331
332 let config: RepoConfig = if config_path.exists() {
334 let toml_str = std::fs::read_to_string(&config_path)
335 .map_err(|e| format!("Failed to read config.toml: {e}"))?;
336 toml::from_str(&toml_str).map_err(|e| format!("Failed to parse config.toml: {e}"))?
337 } else {
338 RepoConfig {
339 project: RepoProjectMeta {
340 name: dir
341 .file_name()
342 .unwrap_or_default()
343 .to_string_lossy()
344 .to_string(),
345 frontier_id: None,
346 compiled_at: String::new(),
347 description: String::new(),
348 compiler: default_compiler(),
349 papers_processed: 0,
350 },
351 }
352 };
353
354 let findings_dir = dir.join(".vela/findings");
356 let mut findings: Vec<FindingBundle> = Vec::new();
357
358 if findings_dir.is_dir() {
359 let mut entries: Vec<PathBuf> = std::fs::read_dir(&findings_dir)
360 .map_err(|e| format!("Failed to read findings/: {e}"))?
361 .filter_map(|e| e.ok())
362 .map(|e| e.path())
363 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
364 .collect();
365 entries.sort();
366
367 for path in entries {
368 let data = std::fs::read_to_string(&path)
369 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
370 let finding: FindingBundle = serde_json::from_str(&data)
371 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
372 findings.push(finding);
373 }
374 }
375
376 let links_dir = dir.join(".vela/links");
378 let manifest_path = links_dir.join("manifest.json");
379 if manifest_path.exists() {
380 let data = std::fs::read_to_string(&manifest_path)
381 .map_err(|e| format!("Failed to read links/manifest.json: {e}"))?;
382 let manifest_links: Vec<ManifestLink> = serde_json::from_str(&data)
383 .map_err(|e| format!("Failed to parse links/manifest.json: {e}"))?;
384
385 let mut links_by_source: HashMap<String, Vec<Link>> = HashMap::new();
387 for ml in manifest_links {
388 links_by_source
389 .entry(ml.source.clone())
390 .or_default()
391 .push(Link {
392 target: ml.target,
393 link_type: ml.link_type,
394 note: ml.note,
395 inferred_by: ml.inferred_by,
396 created_at: ml.created_at,
397 mechanism: None,
398 });
399 }
400
401 for finding in &mut findings {
403 if let Some(links) = links_by_source.remove(&finding.id) {
404 finding.links = links;
405 }
406 }
407 }
408
409 let reviews_dir = dir.join(".vela/reviews");
411 let mut review_events: Vec<ReviewEvent> = Vec::new();
412 if reviews_dir.is_dir() {
413 let mut entries: Vec<PathBuf> = std::fs::read_dir(&reviews_dir)
414 .map_err(|e| format!("Failed to read reviews/: {e}"))?
415 .filter_map(|e| e.ok())
416 .map(|e| e.path())
417 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
418 .collect();
419 entries.sort();
420
421 for path in entries {
422 let data = std::fs::read_to_string(&path)
423 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
424 let event: ReviewEvent = serde_json::from_str(&data)
425 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
426 review_events.push(event);
427 }
428 }
429
430 let confidence_updates_dir = dir.join(".vela/confidence-updates");
431 let mut confidence_updates: Vec<ConfidenceUpdate> = Vec::new();
432 if confidence_updates_dir.is_dir() {
433 let mut entries: Vec<PathBuf> = std::fs::read_dir(&confidence_updates_dir)
434 .map_err(|e| format!("Failed to read confidence-updates/: {e}"))?
435 .filter_map(|e| e.ok())
436 .map(|e| e.path())
437 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
438 .collect();
439 entries.sort();
440
441 for path in entries {
442 let data = std::fs::read_to_string(&path)
443 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
444 let update: ConfidenceUpdate = serde_json::from_str(&data)
445 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
446 confidence_updates.push(update);
447 }
448 }
449 let events_dir = dir.join(".vela/events");
450 let proposals_dir = dir.join(".vela/proposals");
451 let proof_state_path = vela_dir.join("proof-state.json");
452 let mut events: Vec<StateEvent> = Vec::new();
453 if events_dir.is_dir() {
454 let mut entries: Vec<PathBuf> = std::fs::read_dir(&events_dir)
455 .map_err(|e| format!("Failed to read events/: {e}"))?
456 .filter_map(|e| e.ok())
457 .map(|e| e.path())
458 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
459 .collect();
460 entries.sort();
461
462 for path in entries {
463 let data = std::fs::read_to_string(&path)
464 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
465 let event: StateEvent = serde_json::from_str(&data)
466 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
467 events.push(event);
468 }
469 }
470 let mut proposals: Vec<StateProposal> = Vec::new();
471 if proposals_dir.is_dir() {
472 let mut entries: Vec<PathBuf> = std::fs::read_dir(&proposals_dir)
473 .map_err(|e| format!("Failed to read proposals/: {e}"))?
474 .filter_map(|e| e.ok())
475 .map(|e| e.path())
476 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
477 .collect();
478 entries.sort();
479
480 for path in entries {
481 let data = std::fs::read_to_string(&path)
482 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
483 let proposal: StateProposal = serde_json::from_str(&data)
484 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
485 proposals.push(proposal);
486 }
487 }
488 let proof_state = if proof_state_path.is_file() {
489 let data = std::fs::read_to_string(&proof_state_path)
490 .map_err(|e| format!("Failed to read {}: {e}", proof_state_path.display()))?;
491 serde_json::from_str::<ProofState>(&data)
492 .map_err(|e| format!("Failed to parse {}: {e}", proof_state_path.display()))?
493 } else {
494 ProofState::default()
495 };
496
497 let replications_dir = dir.join(".vela/replications");
501 let mut replications: Vec<crate::bundle::Replication> = Vec::new();
502 if replications_dir.is_dir() {
503 let mut entries: Vec<PathBuf> = std::fs::read_dir(&replications_dir)
504 .map_err(|e| format!("Failed to read replications/: {e}"))?
505 .filter_map(|e| e.ok())
506 .map(|e| e.path())
507 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
508 .collect();
509 entries.sort();
510
511 for path in entries {
512 let data = std::fs::read_to_string(&path)
513 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
514 let replication: crate::bundle::Replication = serde_json::from_str(&data)
515 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
516 replications.push(replication);
517 }
518 }
519
520 let datasets_dir = dir.join(".vela/datasets");
525 let mut datasets: Vec<crate::bundle::Dataset> = Vec::new();
526 if datasets_dir.is_dir() {
527 let mut entries: Vec<PathBuf> = std::fs::read_dir(&datasets_dir)
528 .map_err(|e| format!("Failed to read datasets/: {e}"))?
529 .filter_map(|e| e.ok())
530 .map(|e| e.path())
531 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
532 .collect();
533 entries.sort();
534 for path in entries {
535 let data = std::fs::read_to_string(&path)
536 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
537 let dataset: crate::bundle::Dataset = serde_json::from_str(&data)
538 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
539 datasets.push(dataset);
540 }
541 }
542
543 let code_artifacts_dir = dir.join(".vela/code-artifacts");
544 let mut code_artifacts: Vec<crate::bundle::CodeArtifact> = Vec::new();
545 if code_artifacts_dir.is_dir() {
546 let mut entries: Vec<PathBuf> = std::fs::read_dir(&code_artifacts_dir)
547 .map_err(|e| format!("Failed to read code-artifacts/: {e}"))?
548 .filter_map(|e| e.ok())
549 .map(|e| e.path())
550 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
551 .collect();
552 entries.sort();
553 for path in entries {
554 let data = std::fs::read_to_string(&path)
555 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
556 let artifact: crate::bundle::CodeArtifact = serde_json::from_str(&data)
557 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
558 code_artifacts.push(artifact);
559 }
560 }
561
562 let artifacts_dir = dir.join(".vela/artifacts");
563 let mut artifacts: Vec<crate::bundle::Artifact> = Vec::new();
564 if artifacts_dir.is_dir() {
565 let mut entries: Vec<PathBuf> = std::fs::read_dir(&artifacts_dir)
566 .map_err(|e| format!("Failed to read artifacts/: {e}"))?
567 .filter_map(|e| e.ok())
568 .map(|e| e.path())
569 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
570 .collect();
571 entries.sort();
572 for path in entries {
573 let data = std::fs::read_to_string(&path)
574 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
575 let artifact: crate::bundle::Artifact = serde_json::from_str(&data)
576 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
577 artifacts.push(artifact);
578 }
579 }
580
581 let predictions_dir = dir.join(".vela/predictions");
586 let mut predictions: Vec<crate::bundle::Prediction> = Vec::new();
587 if predictions_dir.is_dir() {
588 let mut entries: Vec<PathBuf> = std::fs::read_dir(&predictions_dir)
589 .map_err(|e| format!("Failed to read predictions/: {e}"))?
590 .filter_map(|e| e.ok())
591 .map(|e| e.path())
592 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
593 .collect();
594 entries.sort();
595 for path in entries {
596 let data = std::fs::read_to_string(&path)
597 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
598 let prediction: crate::bundle::Prediction = serde_json::from_str(&data)
599 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
600 predictions.push(prediction);
601 }
602 }
603
604 let resolutions_dir = dir.join(".vela/resolutions");
605 let mut resolutions: Vec<crate::bundle::Resolution> = Vec::new();
606 if resolutions_dir.is_dir() {
607 let mut entries: Vec<PathBuf> = std::fs::read_dir(&resolutions_dir)
608 .map_err(|e| format!("Failed to read resolutions/: {e}"))?
609 .filter_map(|e| e.ok())
610 .map(|e| e.path())
611 .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
612 .collect();
613 entries.sort();
614 for path in entries {
615 let data = std::fs::read_to_string(&path)
616 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
617 let resolution: crate::bundle::Resolution = serde_json::from_str(&data)
618 .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
619 resolutions.push(resolution);
620 }
621 }
622
623 let peers_path = dir.join(".vela/peers.json");
628 let peers: Vec<crate::federation::PeerHub> = if peers_path.is_file() {
629 let data = std::fs::read_to_string(&peers_path)
630 .map_err(|e| format!("Failed to read {}: {e}", peers_path.display()))?;
631 serde_json::from_str(&data)
632 .map_err(|e| format!("Failed to parse {}: {e}", peers_path.display()))?
633 } else {
634 Vec::new()
635 };
636
637 let actors_path = dir.join(".vela/actors.json");
640 let actors: Vec<crate::sign::ActorRecord> = if actors_path.is_file() {
641 let data = std::fs::read_to_string(&actors_path)
642 .map_err(|e| format!("Failed to read {}: {e}", actors_path.display()))?;
643 serde_json::from_str(&data)
644 .map_err(|e| format!("Failed to parse {}: {e}", actors_path.display()))?
645 } else {
646 Vec::new()
647 };
648
649 let signatures_path = dir.join(".vela/signatures.json");
650 let signatures: Vec<crate::sign::SignedEnvelope> = if signatures_path.is_file() {
651 let data = std::fs::read_to_string(&signatures_path)
652 .map_err(|e| format!("Failed to read {}: {e}", signatures_path.display()))?;
653 serde_json::from_str(&data)
654 .map_err(|e| format!("Failed to parse {}: {e}", signatures_path.display()))?
655 } else {
656 Vec::new()
657 };
658
659 let manifest = crate::frontier_repo::manifest_overrides(dir)?;
660
661 let manifest_name = manifest
664 .as_ref()
665 .map(|m| m.name.as_str())
666 .unwrap_or(config.project.name.as_str());
667 let manifest_description = manifest
668 .as_ref()
669 .map(|m| m.description.as_str())
670 .unwrap_or(config.project.description.as_str());
671 let manifest_deps: Vec<project::ProjectDependency> = manifest
679 .as_ref()
680 .map(|m| m.dependencies.frontiers_v2.clone())
681 .unwrap_or_default();
682 let mut c = project::assemble(
683 manifest_name,
684 findings,
685 config.project.papers_processed,
686 0,
687 manifest_description,
688 );
689 if !config.project.compiled_at.is_empty() {
690 c.project.compiled_at = config.project.compiled_at;
691 }
692 c.project.compiler = config.project.compiler;
693 if !manifest_deps.is_empty() {
694 c.project.dependencies = manifest_deps;
695 }
696 let configured_frontier_id = manifest
697 .and_then(|m| m.frontier_id)
698 .or(config.project.frontier_id);
699 c.review_events = review_events;
700 c.confidence_updates = confidence_updates;
701 c.events = events;
702 c.frontier_id = configured_frontier_id.or_else(|| project::frontier_id_from_genesis(&c.events));
703 c.proposals = proposals;
704 c.proof_state = proof_state;
705 c.actors = actors;
706 c.signatures = signatures;
707 c.replications = replications;
708 c.datasets = datasets;
709 c.code_artifacts = code_artifacts;
710 c.artifacts = artifacts;
711 c.predictions = predictions;
712 c.resolutions = resolutions;
713 c.peers = peers;
714
715 materialize_trajectories_and_nulls_from_events(&mut c);
725
726 materialize_evidence_atom_locators_from_events(&mut c);
734
735 project::recompute_stats(&mut c);
736
737 Ok(c)
738}
739
740fn materialize_evidence_atom_locators_from_events(p: &mut Project) {
749 for ev in &p.events {
750 if ev.kind != "evidence_atom.locator_repaired" {
751 continue;
752 }
753 if ev.target.r#type != "evidence_atom" {
754 continue;
755 }
756 let atom_id = ev.target.id.as_str();
757 let locator = match ev.payload.get("locator").and_then(|v| v.as_str()) {
758 Some(value) if !value.is_empty() => value.to_string(),
759 _ => continue,
760 };
761 if let Some(atom) = p.evidence_atoms.iter_mut().find(|a| a.id == atom_id)
762 && atom.locator.is_none()
763 {
764 atom.locator = Some(locator);
765 atom.caveats.retain(|c| c != "missing evidence locator");
766 }
767 }
768}
769
770fn materialize_trajectories_and_nulls_from_events(p: &mut Project) {
778 use crate::bundle::{NegativeResult, Trajectory, TrajectoryStep};
779
780 let mut trajectories: std::collections::HashMap<String, Trajectory> =
781 std::collections::HashMap::new();
782 let mut nulls: std::collections::HashMap<String, NegativeResult> =
783 std::collections::HashMap::new();
784
785 for ev in &p.events {
786 match ev.kind.as_str() {
787 "trajectory.created" => {
788 if let Some(traj_value) = ev.payload.get("trajectory")
789 && let Ok(traj) = serde_json::from_value::<Trajectory>(traj_value.clone())
790 {
791 trajectories.insert(traj.id.clone(), traj);
792 }
793 }
794 "trajectory.step_appended" => {
795 let traj_id = ev.target.id.clone();
796 if let Some(step_value) = ev.payload.get("step")
797 && let Ok(step) = serde_json::from_value::<TrajectoryStep>(step_value.clone())
798 && let Some(traj) = trajectories.get_mut(&traj_id)
799 {
800 traj.steps.push(step);
801 }
802 }
803 "trajectory.retracted" => {
804 if let Some(traj) = trajectories.get_mut(&ev.target.id) {
805 traj.retracted = true;
806 }
807 }
808 "negative_result.asserted" => {
809 if let Some(nr_value) = ev.payload.get("negative_result")
810 && let Ok(nr) = serde_json::from_value::<NegativeResult>(nr_value.clone())
811 {
812 nulls.insert(nr.id.clone(), nr);
813 }
814 }
815 "negative_result.retracted" => {
816 if let Some(nr) = nulls.get_mut(&ev.target.id) {
817 nr.retracted = true;
818 }
819 }
820 _ => {}
821 }
822 }
823
824 if !trajectories.is_empty() {
825 let mut traj_vec: Vec<Trajectory> = trajectories.into_values().collect();
826 traj_vec.sort_by(|a, b| a.id.cmp(&b.id));
827 p.trajectories = traj_vec;
828 }
829 if !nulls.is_empty() {
830 let mut nr_vec: Vec<NegativeResult> = nulls.into_values().collect();
831 nr_vec.sort_by(|a, b| a.id.cmp(&b.id));
832 p.negative_results = nr_vec;
833 }
834}
835
836pub fn save(source: &VelaSource, project: &Project) -> Result<(), String> {
840 match source {
841 VelaSource::ProjectFile(path) => save_project_file(path, project),
842 VelaSource::VelaRepo(dir) => save_vela_repo(dir, project),
843 VelaSource::PacketDir(dir) => Err(format!(
844 "Cannot save directly into packet directory '{}'. Export a new packet instead.",
845 dir.display()
846 )),
847 }
848}
849
850fn save_project_file(path: &Path, project: &Project) -> Result<(), String> {
851 let json = serde_json::to_string_pretty(project)
852 .map_err(|e| format!("Failed to serialize project: {e}"))?;
853 std::fs::write(path, json)
854 .map_err(|e| format!("Failed to write project file '{}': {e}", path.display()))
855}
856
857fn save_vela_repo(dir: &Path, project: &Project) -> Result<(), String> {
858 let vela_dir = dir.join(".vela");
859 let findings_dir = vela_dir.join("findings");
860 let events_dir = vela_dir.join("events");
861 let proposals_dir = vela_dir.join("proposals");
862 let replications_dir = vela_dir.join("replications");
865 let datasets_dir = vela_dir.join("datasets");
867 let code_artifacts_dir = vela_dir.join("code-artifacts");
868 let artifacts_dir = vela_dir.join("artifacts");
869 let predictions_dir = vela_dir.join("predictions");
871 let resolutions_dir = vela_dir.join("resolutions");
872
873 for d in [
875 &vela_dir,
876 &findings_dir,
877 &events_dir,
878 &proposals_dir,
879 &replications_dir,
880 &datasets_dir,
881 &code_artifacts_dir,
882 &artifacts_dir,
883 &predictions_dir,
884 &resolutions_dir,
885 ] {
886 std::fs::create_dir_all(d)
887 .map_err(|e| format!("Failed to create directory {}: {e}", d.display()))?;
888 }
889
890 let config = RepoConfig {
892 project: RepoProjectMeta {
893 name: project.project.name.clone(),
894 frontier_id: Some(project.frontier_id()),
895 compiled_at: project.project.compiled_at.clone(),
896 description: project.project.description.clone(),
897 compiler: project.project.compiler.clone(),
898 papers_processed: project.project.papers_processed,
899 },
900 };
901 let toml_str = toml::to_string_pretty(&config)
902 .map_err(|e| format!("Failed to serialize config.toml: {e}"))?;
903 std::fs::write(vela_dir.join("config.toml"), toml_str)
904 .map_err(|e| format!("Failed to write config.toml: {e}"))?;
905
906 for finding in &project.findings {
909 let json = serde_json::to_string_pretty(finding)
910 .map_err(|e| format!("Failed to serialize finding {}: {e}", finding.id))?;
911 let filename = format!("{}.json", finding.id);
912 std::fs::write(findings_dir.join(&filename), json)
913 .map_err(|e| format!("Failed to write {}: {e}", filename))?;
914 }
915
916 for event in &project.events {
917 let json = serde_json::to_string_pretty(event)
918 .map_err(|e| format!("Failed to serialize state event {}: {e}", event.id))?;
919 let filename = format!("{}.json", event.id);
920 std::fs::write(events_dir.join(&filename), json)
921 .map_err(|e| format!("Failed to write event {}: {e}", filename))?;
922 }
923
924 for proposal in &project.proposals {
925 let json = serde_json::to_string_pretty(proposal)
926 .map_err(|e| format!("Failed to serialize proposal {}: {e}", proposal.id))?;
927 let filename = format!("{}.json", proposal.id);
928 std::fs::write(proposals_dir.join(&filename), json)
929 .map_err(|e| format!("Failed to write proposal {}: {e}", filename))?;
930 }
931
932 let proof_state_json = serde_json::to_string_pretty(&project.proof_state)
933 .map_err(|e| format!("Failed to serialize proof state: {e}"))?;
934 std::fs::write(vela_dir.join("proof-state.json"), proof_state_json)
935 .map_err(|e| format!("Failed to write proof-state.json: {e}"))?;
936
937 for replication in &project.replications {
939 let json = serde_json::to_string_pretty(replication)
940 .map_err(|e| format!("Failed to serialize replication {}: {e}", replication.id))?;
941 let filename = format!("{}.json", replication.id);
942 std::fs::write(replications_dir.join(&filename), json)
943 .map_err(|e| format!("Failed to write replication {}: {e}", filename))?;
944 }
945
946 for dataset in &project.datasets {
949 let json = serde_json::to_string_pretty(dataset)
950 .map_err(|e| format!("Failed to serialize dataset {}: {e}", dataset.id))?;
951 let filename = format!("{}.json", dataset.id);
952 std::fs::write(datasets_dir.join(&filename), json)
953 .map_err(|e| format!("Failed to write dataset {}: {e}", filename))?;
954 }
955 for artifact in &project.code_artifacts {
956 let json = serde_json::to_string_pretty(artifact)
957 .map_err(|e| format!("Failed to serialize code artifact {}: {e}", artifact.id))?;
958 let filename = format!("{}.json", artifact.id);
959 std::fs::write(code_artifacts_dir.join(&filename), json)
960 .map_err(|e| format!("Failed to write code artifact {}: {e}", filename))?;
961 }
962
963 for artifact in &project.artifacts {
964 let json = serde_json::to_string_pretty(artifact)
965 .map_err(|e| format!("Failed to serialize artifact {}: {e}", artifact.id))?;
966 let filename = format!("{}.json", artifact.id);
967 std::fs::write(artifacts_dir.join(&filename), json)
968 .map_err(|e| format!("Failed to write artifact {}: {e}", filename))?;
969 }
970
971 for prediction in &project.predictions {
973 let json = serde_json::to_string_pretty(prediction)
974 .map_err(|e| format!("Failed to serialize prediction {}: {e}", prediction.id))?;
975 let filename = format!("{}.json", prediction.id);
976 std::fs::write(predictions_dir.join(&filename), json)
977 .map_err(|e| format!("Failed to write prediction {}: {e}", filename))?;
978 }
979 for resolution in &project.resolutions {
980 let json = serde_json::to_string_pretty(resolution)
981 .map_err(|e| format!("Failed to serialize resolution {}: {e}", resolution.id))?;
982 let filename = format!("{}.json", resolution.id);
983 std::fs::write(resolutions_dir.join(&filename), json)
984 .map_err(|e| format!("Failed to write resolution {}: {e}", filename))?;
985 }
986
987 let peers_path = vela_dir.join("peers.json");
992 if project.peers.is_empty() {
993 if peers_path.is_file() {
995 std::fs::remove_file(&peers_path)
996 .map_err(|e| format!("Failed to remove stale peers.json: {e}"))?;
997 }
998 } else {
999 let json = serde_json::to_string_pretty(&project.peers)
1000 .map_err(|e| format!("Failed to serialize peers: {e}"))?;
1001 std::fs::write(&peers_path, json)
1002 .map_err(|e| format!("Failed to write peers.json: {e}"))?;
1003 }
1004
1005 let actors_path = vela_dir.join("actors.json");
1006 let json = serde_json::to_string_pretty(&project.actors)
1007 .map_err(|e| format!("Failed to serialize actors: {e}"))?;
1008 std::fs::write(&actors_path, json).map_err(|e| format!("Failed to write actors.json: {e}"))?;
1009
1010 let signatures_path = vela_dir.join("signatures.json");
1011 if project.signatures.is_empty() {
1012 if signatures_path.is_file() {
1013 std::fs::remove_file(&signatures_path)
1014 .map_err(|e| format!("Failed to remove stale signatures.json: {e}"))?;
1015 }
1016 } else {
1017 let json = serde_json::to_string_pretty(&project.signatures)
1018 .map_err(|e| format!("Failed to serialize signatures: {e}"))?;
1019 std::fs::write(&signatures_path, json)
1020 .map_err(|e| format!("Failed to write signatures.json: {e}"))?;
1021 }
1022
1023 crate::frontier_repo::write_visible_repo_files(dir, project)?;
1024
1025 Ok(())
1026}
1027
1028pub fn load_from_path(path: &Path) -> Result<Project, String> {
1032 let source = detect(path)?;
1033 load(&source)
1034}
1035
1036fn is_packet_dir(path: &Path) -> bool {
1037 let manifest_path = path.join("manifest.json");
1038 if !manifest_path.is_file() {
1039 return false;
1040 }
1041 let Ok(data) = std::fs::read_to_string(&manifest_path) else {
1042 return false;
1043 };
1044 let Ok(manifest) = serde_json::from_str::<PacketManifestHeader>(&data) else {
1045 return false;
1046 };
1047 manifest.packet_format == "vela.frontier-packet"
1048}
1049
1050fn first_non_empty<'a>(values: impl IntoIterator<Item = &'a str>) -> &'a str {
1051 values
1052 .into_iter()
1053 .find(|value| !value.is_empty())
1054 .unwrap_or("")
1055}
1056
1057pub fn save_to_path(path: &Path, project: &Project) -> Result<(), String> {
1059 let source = detect(path)?;
1060 save(&source, project)
1061}
1062
1063pub fn init_repo(dir: &Path, project: &Project) -> Result<(), String> {
1066 let vela_dir = dir.join(".vela");
1067 std::fs::create_dir_all(&vela_dir).map_err(|e| format!("Failed to create .vela/: {e}"))?;
1068 save_vela_repo(dir, project)
1069}
1070
1071#[cfg(test)]
1074mod tests {
1075 use super::*;
1076 use crate::bundle::*;
1077 use crate::project;
1078 use tempfile::TempDir;
1079
1080 fn make_finding(id: &str, score: f64, assertion_type: &str) -> FindingBundle {
1081 FindingBundle {
1082 id: id.into(),
1083 version: 1,
1084 previous_version: None,
1085 assertion: Assertion {
1086 text: format!("Finding {id}"),
1087 assertion_type: assertion_type.into(),
1088 entities: vec![Entity {
1089 name: "TestEntity".into(),
1090 entity_type: "protein".into(),
1091 identifiers: serde_json::Map::new(),
1092 canonical_id: None,
1093 candidates: vec![],
1094 aliases: vec![],
1095 resolution_provenance: None,
1096 resolution_confidence: 1.0,
1097 resolution_method: None,
1098 species_context: None,
1099 needs_review: false,
1100 }],
1101 relation: None,
1102 direction: None,
1103 causal_claim: None,
1104 causal_evidence_grade: None,
1105 },
1106 evidence: Evidence {
1107 evidence_type: "experimental".into(),
1108 model_system: String::new(),
1109 species: None,
1110 method: String::new(),
1111 sample_size: None,
1112 effect_size: None,
1113 p_value: None,
1114 replicated: false,
1115 replication_count: None,
1116 evidence_spans: vec![],
1117 },
1118 conditions: Conditions {
1119 text: String::new(),
1120 species_verified: vec![],
1121 species_unverified: vec![],
1122 in_vitro: false,
1123 in_vivo: false,
1124 human_data: false,
1125 clinical_trial: false,
1126 concentration_range: None,
1127 duration: None,
1128 age_group: None,
1129 cell_type: None,
1130 },
1131 confidence: Confidence::raw(score, "seeded prior", 0.85),
1132 provenance: Provenance {
1133 source_type: "published_paper".into(),
1134 doi: None,
1135 pmid: None,
1136 pmc: None,
1137 openalex_id: None,
1138 url: None,
1139 title: "Test".into(),
1140 authors: vec![],
1141 year: Some(2024),
1142 journal: None,
1143 license: None,
1144 publisher: None,
1145 funders: vec![],
1146 extraction: Extraction::default(),
1147 review: None,
1148 citation_count: None,
1149 },
1150 flags: Flags {
1151 gap: false,
1152 negative_space: false,
1153 contested: false,
1154 retracted: false,
1155 declining: false,
1156 gravity_well: false,
1157 review_state: None,
1158 superseded: false,
1159 signature_threshold: None,
1160 jointly_accepted: false,
1161 },
1162 links: vec![],
1163 annotations: vec![],
1164 attachments: vec![],
1165 created: String::new(),
1166 updated: None,
1167
1168 access_tier: crate::access_tier::AccessTier::Public,
1169 }
1170 }
1171
1172 fn make_project(name: &str, findings: Vec<FindingBundle>) -> Project {
1173 project::assemble(name, findings, 10, 0, "Test project")
1174 }
1175
1176 #[test]
1179 fn detect_json_file() {
1180 let tmp = TempDir::new().unwrap();
1181 let json_path = tmp.path().join("test.json");
1182 std::fs::write(&json_path, "{}").unwrap();
1183 let source = detect(&json_path).unwrap();
1184 assert_eq!(source, VelaSource::ProjectFile(json_path));
1185 }
1186
1187 #[test]
1188 fn detect_vela_repo() {
1189 let tmp = TempDir::new().unwrap();
1190 let repo_dir = tmp.path().join("my-repo");
1191 std::fs::create_dir_all(repo_dir.join(".vela")).unwrap();
1192 let source = detect(&repo_dir).unwrap();
1193 assert_eq!(source, VelaSource::VelaRepo(repo_dir));
1194 }
1195
1196 #[test]
1197 fn detect_dir_without_vela_errors() {
1198 let tmp = TempDir::new().unwrap();
1199 let dir = tmp.path().join("plain-dir");
1200 std::fs::create_dir_all(&dir).unwrap();
1201 let result = detect(&dir);
1202 assert!(result.is_err());
1203 let error = result.unwrap_err();
1204 assert!(error.contains("frontier packet"));
1205 assert!(error.contains("vela init"));
1206 }
1207
1208 #[test]
1209 fn detect_nonexistent_json_path() {
1210 let path = Path::new("/tmp/nonexistent_test_vela.json");
1211 let source = detect(path).unwrap();
1212 assert_eq!(source, VelaSource::ProjectFile(path.to_path_buf()));
1213 }
1214
1215 #[test]
1216 fn detect_nonexistent_non_json_errors() {
1217 let path = Path::new("/tmp/nonexistent_test_vela_dir");
1218 let result = detect(path);
1219 assert!(result.is_err());
1220 }
1221
1222 #[test]
1225 fn roundtrip_project_file() {
1226 let tmp = TempDir::new().unwrap();
1227 let path = tmp.path().join("test.json");
1228
1229 let mut f1 = make_finding("vf_001", 0.8, "mechanism");
1230 f1.add_link("vf_002", "extends", "shared entity");
1231 let f2 = make_finding("vf_002", 0.6, "therapeutic");
1232 let original = make_project("roundtrip-test", vec![f1, f2]);
1233
1234 let source = VelaSource::ProjectFile(path.clone());
1235 save(&source, &original).unwrap();
1236 let loaded = load(&source).unwrap();
1237
1238 assert_eq!(loaded.findings.len(), 2);
1239 assert_eq!(loaded.project.name, "roundtrip-test");
1240 assert_eq!(loaded.findings[0].links.len(), 1);
1241 assert_eq!(loaded.findings[0].links[0].target, "vf_002");
1242 }
1243
1244 #[test]
1247 fn roundtrip_vela_repo() {
1248 let tmp = TempDir::new().unwrap();
1249 let dir = tmp.path().join("test-repo");
1250
1251 let mut f1 = make_finding("vf_aaa", 0.9, "mechanism");
1252 f1.add_link("vf_bbb", "contradicts", "opposite direction");
1253 f1.add_link("vf_ccc", "supports", "same pathway");
1254 let f2 = make_finding("vf_bbb", 0.7, "therapeutic");
1255 let f3 = make_finding("vf_ccc", 0.5, "biomarker");
1256 let original = make_project("repo-test", vec![f1, f2, f3]);
1257
1258 init_repo(&dir, &original).unwrap();
1259
1260 assert!(dir.join(".vela").is_dir());
1262 assert!(dir.join(".vela/config.toml").exists());
1263 assert!(dir.join(".vela/findings").is_dir());
1264 assert!(dir.join(".vela/findings/vf_aaa.json").exists());
1265 assert!(dir.join(".vela/findings/vf_bbb.json").exists());
1266 assert!(dir.join(".vela/findings/vf_ccc.json").exists());
1267 assert!(dir.join(".vela/events").is_dir());
1268 assert!(dir.join(".vela/proposals").is_dir());
1269 assert!(dir.join(".vela/proof-state.json").exists());
1270 assert!(!dir.join(".vela/links/manifest.json").exists());
1271 assert!(!dir.join(".vela/reviews").exists());
1272
1273 let source = VelaSource::VelaRepo(dir);
1275 let loaded = load(&source).unwrap();
1276
1277 assert_eq!(loaded.findings.len(), 3);
1278 assert_eq!(loaded.project.name, "repo-test");
1279 assert_eq!(loaded.project.description, "Test project");
1280
1281 let f1_loaded = loaded.findings.iter().find(|f| f.id == "vf_aaa").unwrap();
1283 assert_eq!(f1_loaded.links.len(), 2);
1284 let f2_loaded = loaded.findings.iter().find(|f| f.id == "vf_bbb").unwrap();
1285 assert!(f2_loaded.links.is_empty());
1286 }
1287
1288 #[test]
1291 fn embedded_links_roundtrip() {
1292 let tmp = TempDir::new().unwrap();
1293 let dir = tmp.path().join("link-test");
1294
1295 let mut f1 = make_finding("vf_x1", 0.8, "mechanism");
1296 f1.add_link("vf_x2", "extends", "entity overlap");
1297 f1.add_link_with_source("vf_x3", "supports", "pathway link", "llm");
1298 let mut f2 = make_finding("vf_x2", 0.7, "mechanism");
1299 f2.add_link("vf_x1", "contradicts", "opposite");
1300 let f3 = make_finding("vf_x3", 0.6, "therapeutic");
1301
1302 let original = make_project("link-test", vec![f1, f2, f3]);
1303 init_repo(&dir, &original).unwrap();
1304
1305 assert!(!dir.join(".vela/links/manifest.json").exists());
1306
1307 let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1309 let lf1 = loaded.findings.iter().find(|f| f.id == "vf_x1").unwrap();
1310 assert_eq!(lf1.links.len(), 2);
1311 let lf2 = loaded.findings.iter().find(|f| f.id == "vf_x2").unwrap();
1312 assert_eq!(lf2.links.len(), 1);
1313 assert_eq!(lf2.links[0].link_type, "contradicts");
1314 }
1315
1316 #[test]
1319 fn config_toml_parsing() {
1320 let toml_str = r#"
1321[project]
1322name = "alzheimers-tau"
1323description = "Tau pathology in Alzheimer's disease"
1324compiler = "vela/0.2.0"
1325papers_processed = 700
1326"#;
1327 let config: RepoConfig = toml::from_str(toml_str).unwrap();
1328 assert_eq!(config.project.name, "alzheimers-tau");
1329 assert_eq!(
1330 config.project.description,
1331 "Tau pathology in Alzheimer's disease"
1332 );
1333 assert_eq!(config.project.papers_processed, 700);
1334 assert_eq!(config.project.compiler, "vela/0.2.0");
1335 assert_eq!(config.project.frontier_id, None);
1336 assert_eq!(config.project.compiled_at, "");
1337 }
1338
1339 #[test]
1340 fn config_toml_minimal() {
1341 let toml_str = r#"
1342[project]
1343name = "minimal"
1344"#;
1345 let config: RepoConfig = toml::from_str(toml_str).unwrap();
1346 assert_eq!(config.project.name, "minimal");
1347 assert_eq!(config.project.description, "");
1348 assert_eq!(config.project.papers_processed, 0);
1349 }
1350
1351 #[test]
1352 fn vela_repo_persists_frontier_id_and_actors() {
1353 let tmp = TempDir::new().unwrap();
1354 let dir = tmp.path().join("actor-repo");
1355
1356 let mut original = make_project(
1357 "actor-test",
1358 vec![make_finding("vf_actor", 0.8, "mechanism")],
1359 );
1360 let expected_frontier_id = original.frontier_id();
1361 original.actors.push(crate::sign::ActorRecord {
1362 id: "reviewer:test".into(),
1363 public_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
1364 algorithm: "ed25519".into(),
1365 created_at: "2026-01-01T00:00:00Z".into(),
1366 tier: None,
1367 orcid: None,
1368 access_clearance: None,
1369 revoked_at: None,
1370 revoked_reason: None,
1371 });
1372 original.signatures.push(crate::sign::SignedEnvelope {
1373 finding_id: "vf_actor".into(),
1374 signature: "00".repeat(64),
1375 public_key: "aa".repeat(32),
1376 signed_at: "2026-01-01T00:00:00Z".into(),
1377 algorithm: "ed25519".into(),
1378 });
1379
1380 init_repo(&dir, &original).unwrap();
1381 assert!(dir.join(".vela/actors.json").exists());
1382 assert!(dir.join(".vela/signatures.json").exists());
1383
1384 let first_load = load(&VelaSource::VelaRepo(dir.clone())).unwrap();
1385 let second_load = load(&VelaSource::VelaRepo(dir)).unwrap();
1386
1387 assert_eq!(first_load.frontier_id(), expected_frontier_id);
1388 assert_eq!(second_load.frontier_id(), expected_frontier_id);
1389 assert_eq!(first_load.actors, original.actors);
1390 assert_eq!(first_load.signatures.len(), 1);
1391 assert_eq!(second_load.signatures.len(), 1);
1392 assert_eq!(second_load.signatures[0].finding_id, "vf_actor");
1393 }
1394
1395 #[test]
1398 fn empty_project_roundtrip() {
1399 let tmp = TempDir::new().unwrap();
1400 let dir = tmp.path().join("empty-repo");
1401
1402 let original = make_project("empty", vec![]);
1403 init_repo(&dir, &original).unwrap();
1404
1405 let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1406 assert_eq!(loaded.findings.len(), 0);
1407 assert_eq!(loaded.stats.findings, 0);
1408 assert_eq!(loaded.stats.links, 0);
1409 assert_eq!(loaded.project.name, "empty");
1410 }
1411
1412 #[test]
1413 fn artifacts_roundtrip_from_vela_repo() {
1414 let tmp = TempDir::new().unwrap();
1415 let dir = tmp.path().join("artifact-repo");
1416
1417 let mut original = make_project("artifact-test", vec![]);
1418 let artifact = Artifact::new(
1419 "protocol",
1420 "trial protocol",
1421 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1422 Some(17),
1423 Some("application/json".into()),
1424 "local_blob",
1425 Some(".vela/artifact-blobs/sha256/bbbb".into()),
1426 Some("https://example.test/protocol".into()),
1427 Some("CC0-1.0".into()),
1428 vec!["vf_target".into()],
1429 Provenance {
1430 source_type: "clinical_trial".into(),
1431 doi: None,
1432 pmid: None,
1433 pmc: None,
1434 openalex_id: None,
1435 url: Some("https://example.test/protocol".into()),
1436 title: "trial protocol".into(),
1437 authors: vec![],
1438 year: Some(2026),
1439 journal: None,
1440 license: Some("CC0-1.0".into()),
1441 publisher: None,
1442 funders: vec![],
1443 extraction: Extraction::default(),
1444 review: None,
1445 citation_count: None,
1446 },
1447 std::collections::BTreeMap::new(),
1448 crate::access_tier::AccessTier::Public,
1449 )
1450 .unwrap();
1451 let id = artifact.id.clone();
1452 original.artifacts.push(artifact);
1453 init_repo(&dir, &original).unwrap();
1454
1455 let loaded = load(&VelaSource::VelaRepo(dir.clone())).unwrap();
1456 assert_eq!(loaded.artifacts.len(), 1);
1457 assert_eq!(loaded.artifacts[0].id, id);
1458 assert!(dir.join(".vela/artifacts").is_dir());
1459 }
1460
1461 #[test]
1464 fn large_finding_count() {
1465 let tmp = TempDir::new().unwrap();
1466 let dir = tmp.path().join("large-repo");
1467
1468 let findings: Vec<FindingBundle> = (0..100)
1469 .map(|i| make_finding(&format!("vf_{i:04}"), 0.5 + (i as f64) * 0.004, "mechanism"))
1470 .collect();
1471 let original = make_project("large", findings);
1472 assert_eq!(original.findings.len(), 100);
1473
1474 init_repo(&dir, &original).unwrap();
1475
1476 let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1477 assert_eq!(loaded.findings.len(), 100);
1478 assert_eq!(loaded.stats.findings, 100);
1479 }
1480
1481 #[test]
1484 fn legacy_review_events_load() {
1485 let tmp = TempDir::new().unwrap();
1486 let dir = tmp.path().join("review-repo");
1487
1488 let mut original =
1489 make_project("review-test", vec![make_finding("vf_r1", 0.8, "mechanism")]);
1490 original.review_events.push(ReviewEvent {
1491 id: "rev_001".into(),
1492 workspace: None,
1493 finding_id: "vf_r1".into(),
1494 reviewer: "0000-0001-2345-6789".into(),
1495 reviewed_at: "2024-01-01T00:00:00Z".into(),
1496 scope: None,
1497 status: None,
1498 action: ReviewAction::Approved,
1499 reason: "Looks correct".into(),
1500 evidence_considered: vec![],
1501 state_change: None,
1502 });
1503
1504 init_repo(&dir, &original).unwrap();
1505 assert!(!dir.join(".vela/reviews").exists());
1506 std::fs::create_dir_all(dir.join(".vela/reviews")).unwrap();
1507 std::fs::write(
1508 dir.join(".vela/reviews/rev_001.json"),
1509 serde_json::to_string_pretty(&original.review_events[0]).unwrap(),
1510 )
1511 .unwrap();
1512
1513 let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1514 assert_eq!(loaded.review_events.len(), 1);
1515 assert_eq!(loaded.review_events[0].id, "rev_001");
1516 assert_eq!(loaded.review_events[0].finding_id, "vf_r1");
1517 }
1518
1519 #[test]
1520 fn load_vela_repo_accepts_bbb_review_artifact() {
1521 let tmp = TempDir::new().unwrap();
1522 let dir = tmp.path().join("bbb-review-repo");
1523 std::fs::create_dir_all(dir.join(".vela/reviews")).unwrap();
1524 std::fs::write(
1525 dir.join(".vela/config.toml"),
1526 "[project]\nname = \"bbb-review-repo\"\ndescription = \"\"\ncompiler = \"vela/test\"\npapers_processed = 0\n",
1527 )
1528 .unwrap();
1529 std::fs::write(
1530 dir.join(".vela/reviews/rev_001_bbb_correction.json"),
1531 include_str!("../embedded/tests/fixtures/legacy/rev_001_bbb_correction.json"),
1532 )
1533 .unwrap();
1534
1535 let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1536 assert_eq!(loaded.review_events.len(), 1);
1537 assert!(matches!(
1538 loaded.review_events[0].action,
1539 ReviewAction::Qualified { .. }
1540 ));
1541 assert_eq!(loaded.review_events[0].status.as_deref(), Some("accepted"));
1542 }
1543
1544 #[test]
1547 fn load_from_path_json() {
1548 let tmp = TempDir::new().unwrap();
1549 let path = tmp.path().join("convenience.json");
1550
1551 let original = make_project("convenience", vec![make_finding("vf_c1", 0.8, "mechanism")]);
1552 let json = serde_json::to_string_pretty(&original).unwrap();
1553 std::fs::write(&path, json).unwrap();
1554
1555 let loaded = load_from_path(&path).unwrap();
1556 assert_eq!(loaded.project.name, "convenience");
1557 assert_eq!(loaded.findings.len(), 1);
1558 }
1559
1560 #[test]
1561 fn load_from_path_repo() {
1562 let tmp = TempDir::new().unwrap();
1563 let dir = tmp.path().join("conv-repo");
1564
1565 let original = make_project("conv-repo", vec![make_finding("vf_cr1", 0.8, "mechanism")]);
1566 init_repo(&dir, &original).unwrap();
1567
1568 let loaded = load_from_path(&dir).unwrap();
1569 assert_eq!(loaded.project.name, "conv-repo");
1570 assert_eq!(loaded.findings.len(), 1);
1571 }
1572
1573 #[test]
1574 fn load_from_path_packet_dir() {
1575 let tmp = TempDir::new().unwrap();
1576 let dir = tmp.path().join("packet-frontier");
1577
1578 let mut original = make_project(
1579 "packet-frontier",
1580 vec![make_finding("vf_pkt1", 0.81, "mechanism")],
1581 );
1582 original.review_events.push(ReviewEvent {
1583 id: "rev_pkt1".into(),
1584 workspace: Some("bbb".into()),
1585 finding_id: "vf_pkt1".into(),
1586 reviewer: "reviewer:test".into(),
1587 reviewed_at: "2026-01-01T00:00:00Z".into(),
1588 scope: Some("external".into()),
1589 status: Some("accepted".into()),
1590 action: ReviewAction::Approved,
1591 reason: "Imported from another lab".into(),
1592 evidence_considered: vec![],
1593 state_change: None,
1594 });
1595 original.stats.review_event_count = original.review_events.len();
1596 crate::export::export_packet(&original, &dir).unwrap();
1597
1598 let loaded = load_from_path(&dir).unwrap();
1599 assert_eq!(loaded.project.name, "packet-frontier");
1600 assert_eq!(loaded.findings.len(), 1);
1601 assert_eq!(loaded.review_events.len(), 1);
1602 assert_eq!(loaded.stats.review_event_count, 1);
1603 }
1604
1605 #[test]
1608 fn full_format_roundtrip() {
1609 let tmp = TempDir::new().unwrap();
1610
1611 let mut f1 = make_finding("vf_rt1", 0.85, "mechanism");
1613 f1.add_link("vf_rt2", "extends", "shared protein");
1614 let f2 = make_finding("vf_rt2", 0.72, "therapeutic");
1615
1616 let original = make_project("full-roundtrip", vec![f1, f2]);
1617
1618 let json_path = tmp.path().join("original.json");
1620 save(&VelaSource::ProjectFile(json_path.clone()), &original).unwrap();
1621
1622 let from_json = load(&VelaSource::ProjectFile(json_path)).unwrap();
1624
1625 let repo_dir = tmp.path().join("repo");
1627 init_repo(&repo_dir, &from_json).unwrap();
1628
1629 let from_repo = load(&VelaSource::VelaRepo(repo_dir)).unwrap();
1631
1632 assert_eq!(from_repo.findings.len(), from_json.findings.len());
1634 assert_eq!(from_repo.project.name, from_json.project.name);
1635
1636 let rt1 = from_repo
1637 .findings
1638 .iter()
1639 .find(|f| f.id == "vf_rt1")
1640 .unwrap();
1641 assert_eq!(rt1.links.len(), 1);
1642 assert_eq!(rt1.links[0].target, "vf_rt2");
1643 assert_eq!(rt1.links[0].link_type, "extends");
1644 }
1645}