Skip to main content

vela_protocol/
repo.rs

1//! Git-native VelaRepo abstraction — load/save projects from either monolithic JSON
2//! or a `.vela/` directory of individual finding files.
3
4use 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// ── Source detection ──────────────────────────────────────────────────
15
16/// Where a project lives on disk.
17#[derive(Debug, Clone, PartialEq)]
18pub enum VelaSource {
19    /// A single monolithic JSON file.
20    ProjectFile(PathBuf),
21    /// A directory with a `.vela/` subdirectory containing individual finding files.
22    VelaRepo(PathBuf),
23    /// A publishable frontier packet directory with `manifest.json` and payload files.
24    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
62/// Detect the source type from a path.
63///
64/// - If `path` points to a file with `.json` extension -> ProjectFile
65/// - If `path` is a directory with a `.vela/` subdirectory -> VelaRepo
66/// - Otherwise -> error
67pub 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        // A path that looks like it should be a JSON file but doesn't exist yet
80        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    // Path doesn't exist yet — check extension
89    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// ── Config TOML ──────────────────────────────────────────────────────
99
100#[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// ── Link manifest ────────────────────────────────────────────────────
125
126/// A link record in the centralized manifest. Contains a `source` field
127/// (the finding ID that owns this link) so we can redistribute on load.
128#[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
146// ── Load ─────────────────────────────────────────────────────────────
147
148/// Load a project from a detected source.
149pub 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    // Read config
333    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    // Read findings
355    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    // Read link manifest and redistribute
377    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        // Build a map of source_id -> links
386        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        // Distribute links into findings
402        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    // Read reviews
410    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    // v0.32: Read replications from `.vela/replications/`. Each file
498    // is a single Replication serialized as JSON, content-addressed
499    // by `vrep_<id>.json`. Same pattern as findings.
500    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    // v0.33: Read datasets from `.vela/datasets/` and code artifacts
521    // from `.vela/code-artifacts/`. Same one-file-per-record pattern
522    // as findings and replications. Both directories are optional,
523    // so pre-v0.33 frontiers without them load unchanged.
524    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    // v0.34: predictions and resolutions. One file per record at
582    // `.vela/predictions/<vpred_id>.json` and
583    // `.vela/resolutions/<vres_id>.json`. Same pattern as findings,
584    // replications, datasets, code-artifacts.
585    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    // v0.39: federation peer registry. Stored as a single JSON file
624    // (peers are a small flat list, not content-addressed) at
625    // `.vela/peers.json`. Pre-v0.39 frontiers without the file load
626    // unchanged with an empty peer registry.
627    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    // Actor registry. Stored as one flat JSON file because actors are a
638    // small authority list, not content-addressed frontier objects.
639    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    // Assemble into Project using the project::assemble function for stats,
662    // then patch metadata from config and optional frontier.yaml.
663    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    // v0.59: rehydrate cross-frontier dependencies from the yaml
672    // manifest. Pre-v0.59 these were written into the rendered
673    // `frontier.json` but `vela frontier materialize` regenerated
674    // that file without them, so any cross-frontier link from a
675    // split-repo failed with "no matching dep is declared". The
676    // structured field `manifest.dependencies.frontiers_v2` is the
677    // durable source of truth.
678    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    // v0.55: materialize trajectories and negative_results from the
716    // event log. Pre-v0.55 these primitives existed in the canonical
717    // event stream but never populated `Project.trajectories` or
718    // `Project.negative_results` on load, which meant every downstream
719    // consumer (CLI listing commands like `vela negative-results .`,
720    // `vela trajectory-step`, the export reducer, the Workbench
721    // /trajectories and /negative-results pages, and the marketing
722    // site's render_findings_constellation SVG) saw empty arrays even
723    // though the events were intact in `.vela/events/`.
724    materialize_trajectories_and_nulls_from_events(&mut c);
725
726    // v0.56: replay evidence_atom.locator_repaired events into the
727    // loaded evidence_atoms array. `project::assemble` derives atoms
728    // from findings, which produces atoms with `locator: None` for
729    // findings whose evidence_spans are empty. The repair events in
730    // `.vela/events/` carry the resolved locator and the parent
731    // source id. Without this step, every load erases the curation
732    // work the canonical events recorded.
733    materialize_evidence_atom_locators_from_events(&mut c);
734
735    project::recompute_stats(&mut c);
736
737    Ok(c)
738}
739
740/// v0.56: Walk the event log and apply
741/// `evidence_atom.locator_repaired` events into the materialized
742/// `evidence_atoms` array. Each event names a single atom by id and
743/// sets its `locator`. Idempotent: applying twice with the same
744/// payload is a no-op. Divergent locator overwrites are silently
745/// dropped here because the reducer-side validator already rejected
746/// them at event-emit time; if a stale chain ever made it past the
747/// emit boundary the integrity report flags the divergence.
748fn 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
770/// Walk the event log to populate `Project.trajectories` and
771/// `Project.negative_results` from the canonical
772/// `trajectory.created` / `trajectory.step_appended` /
773/// `trajectory.retracted` / `negative_result.asserted` /
774/// `negative_result.retracted` events. Mirrors what the reducer
775/// would do on a fresh replay; this is the missing materialization
776/// step in `load_vela_repo`.
777fn 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
836// ── Save ─────────────────────────────────────────────────────────────
837
838/// Save a project to a detected source.
839pub 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    // v0.32: structured replications live in their own directory;
863    // each `vrep_<id>.json` is a single Replication record.
864    let replications_dir = vela_dir.join("replications");
865    // v0.33: datasets and code artifacts each get their own directory.
866    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    // v0.34: predictions + resolutions form the epistemic ledger.
870    let predictions_dir = vela_dir.join("predictions");
871    let resolutions_dir = vela_dir.join("resolutions");
872
873    // Create directories
874    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    // Write config.toml
891    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    // Write each finding as findings/{id}.json. Links remain embedded in the
907    // finding bundle; legacy link manifests are still accepted on load.
908    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    // v0.32: write replications as one file per `vrep_<id>.json`.
938    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    // v0.33: datasets and code artifacts as individual `vd_<id>.json`
947    // and `vc_<id>.json` files. Same persistence shape as findings.
948    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    // v0.34: predictions and resolutions, one file per record.
972    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    // v0.39: federation peer registry. One JSON file holding the full
988    // list (peers are flat, not content-addressed). Skip writing the
989    // file when the registry is empty so pre-v0.39 frontiers stay
990    // byte-identical on disk.
991    let peers_path = vela_dir.join("peers.json");
992    if project.peers.is_empty() {
993        // Tidy up a stale file if the last peer was removed.
994        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
1028// ── Convenience ──────────────────────────────────────────────────────
1029
1030/// Detect source type from path, then load.
1031pub 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
1057/// Detect source type from path, then save.
1058pub fn save_to_path(path: &Path, project: &Project) -> Result<(), String> {
1059    let source = detect(path)?;
1060    save(&source, project)
1061}
1062
1063/// Initialize a VelaRepo from a Project at the given directory.
1064/// Creates the minimum public `.vela/` layout and writes frontier state.
1065pub 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// ── Tests ────────────────────────────────────────────────────────────
1072
1073#[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    // ── detect tests ────────────────────────────────────────────────
1177
1178    #[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    // ── roundtrip: project file ────────────────────────────────────
1223
1224    #[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    // ── roundtrip: vela repo ────────────────────────────────────────
1245
1246    #[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        // Verify directory structure
1261        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        // Load back
1274        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        // Check links redistributed correctly
1282        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    // ── links remain embedded in finding bundles ─────────────────────
1289
1290    #[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        // Load back and verify redistribution
1308        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    // ── config.toml parsing ─────────────────────────────────────────
1317
1318    #[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        });
1370        original.signatures.push(crate::sign::SignedEnvelope {
1371            finding_id: "vf_actor".into(),
1372            signature: "00".repeat(64),
1373            public_key: "aa".repeat(32),
1374            signed_at: "2026-01-01T00:00:00Z".into(),
1375            algorithm: "ed25519".into(),
1376        });
1377
1378        init_repo(&dir, &original).unwrap();
1379        assert!(dir.join(".vela/actors.json").exists());
1380        assert!(dir.join(".vela/signatures.json").exists());
1381
1382        let first_load = load(&VelaSource::VelaRepo(dir.clone())).unwrap();
1383        let second_load = load(&VelaSource::VelaRepo(dir)).unwrap();
1384
1385        assert_eq!(first_load.frontier_id(), expected_frontier_id);
1386        assert_eq!(second_load.frontier_id(), expected_frontier_id);
1387        assert_eq!(first_load.actors, original.actors);
1388        assert_eq!(first_load.signatures.len(), 1);
1389        assert_eq!(second_load.signatures.len(), 1);
1390        assert_eq!(second_load.signatures[0].finding_id, "vf_actor");
1391    }
1392
1393    // ── empty project ──────────────────────────────────────────────
1394
1395    #[test]
1396    fn empty_project_roundtrip() {
1397        let tmp = TempDir::new().unwrap();
1398        let dir = tmp.path().join("empty-repo");
1399
1400        let original = make_project("empty", vec![]);
1401        init_repo(&dir, &original).unwrap();
1402
1403        let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1404        assert_eq!(loaded.findings.len(), 0);
1405        assert_eq!(loaded.stats.findings, 0);
1406        assert_eq!(loaded.stats.links, 0);
1407        assert_eq!(loaded.project.name, "empty");
1408    }
1409
1410    #[test]
1411    fn artifacts_roundtrip_from_vela_repo() {
1412        let tmp = TempDir::new().unwrap();
1413        let dir = tmp.path().join("artifact-repo");
1414
1415        let mut original = make_project("artifact-test", vec![]);
1416        let artifact = Artifact::new(
1417            "protocol",
1418            "trial protocol",
1419            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1420            Some(17),
1421            Some("application/json".into()),
1422            "local_blob",
1423            Some(".vela/artifact-blobs/sha256/bbbb".into()),
1424            Some("https://example.test/protocol".into()),
1425            Some("CC0-1.0".into()),
1426            vec!["vf_target".into()],
1427            Provenance {
1428                source_type: "clinical_trial".into(),
1429                doi: None,
1430                pmid: None,
1431                pmc: None,
1432                openalex_id: None,
1433                url: Some("https://example.test/protocol".into()),
1434                title: "trial protocol".into(),
1435                authors: vec![],
1436                year: Some(2026),
1437                journal: None,
1438                license: Some("CC0-1.0".into()),
1439                publisher: None,
1440                funders: vec![],
1441                extraction: Extraction::default(),
1442                review: None,
1443                citation_count: None,
1444            },
1445            std::collections::BTreeMap::new(),
1446            crate::access_tier::AccessTier::Public,
1447        )
1448        .unwrap();
1449        let id = artifact.id.clone();
1450        original.artifacts.push(artifact);
1451        init_repo(&dir, &original).unwrap();
1452
1453        let loaded = load(&VelaSource::VelaRepo(dir.clone())).unwrap();
1454        assert_eq!(loaded.artifacts.len(), 1);
1455        assert_eq!(loaded.artifacts[0].id, id);
1456        assert!(dir.join(".vela/artifacts").is_dir());
1457    }
1458
1459    // ── large finding count ─────────────────────────────────────────
1460
1461    #[test]
1462    fn large_finding_count() {
1463        let tmp = TempDir::new().unwrap();
1464        let dir = tmp.path().join("large-repo");
1465
1466        let findings: Vec<FindingBundle> = (0..100)
1467            .map(|i| make_finding(&format!("vf_{i:04}"), 0.5 + (i as f64) * 0.004, "mechanism"))
1468            .collect();
1469        let original = make_project("large", findings);
1470        assert_eq!(original.findings.len(), 100);
1471
1472        init_repo(&dir, &original).unwrap();
1473
1474        let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1475        assert_eq!(loaded.findings.len(), 100);
1476        assert_eq!(loaded.stats.findings, 100);
1477    }
1478
1479    // ── legacy review events remain readable ─────────────────────────
1480
1481    #[test]
1482    fn legacy_review_events_load() {
1483        let tmp = TempDir::new().unwrap();
1484        let dir = tmp.path().join("review-repo");
1485
1486        let mut original =
1487            make_project("review-test", vec![make_finding("vf_r1", 0.8, "mechanism")]);
1488        original.review_events.push(ReviewEvent {
1489            id: "rev_001".into(),
1490            workspace: None,
1491            finding_id: "vf_r1".into(),
1492            reviewer: "0000-0001-2345-6789".into(),
1493            reviewed_at: "2024-01-01T00:00:00Z".into(),
1494            scope: None,
1495            status: None,
1496            action: ReviewAction::Approved,
1497            reason: "Looks correct".into(),
1498            evidence_considered: vec![],
1499            state_change: None,
1500        });
1501
1502        init_repo(&dir, &original).unwrap();
1503        assert!(!dir.join(".vela/reviews").exists());
1504        std::fs::create_dir_all(dir.join(".vela/reviews")).unwrap();
1505        std::fs::write(
1506            dir.join(".vela/reviews/rev_001.json"),
1507            serde_json::to_string_pretty(&original.review_events[0]).unwrap(),
1508        )
1509        .unwrap();
1510
1511        let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1512        assert_eq!(loaded.review_events.len(), 1);
1513        assert_eq!(loaded.review_events[0].id, "rev_001");
1514        assert_eq!(loaded.review_events[0].finding_id, "vf_r1");
1515    }
1516
1517    #[test]
1518    fn load_vela_repo_accepts_bbb_review_artifact() {
1519        let tmp = TempDir::new().unwrap();
1520        let dir = tmp.path().join("bbb-review-repo");
1521        std::fs::create_dir_all(dir.join(".vela/reviews")).unwrap();
1522        std::fs::write(
1523            dir.join(".vela/config.toml"),
1524            "[project]\nname = \"bbb-review-repo\"\ndescription = \"\"\ncompiler = \"vela/test\"\npapers_processed = 0\n",
1525        )
1526        .unwrap();
1527        std::fs::write(
1528            dir.join(".vela/reviews/rev_001_bbb_correction.json"),
1529            include_str!("../embedded/tests/fixtures/legacy/rev_001_bbb_correction.json"),
1530        )
1531        .unwrap();
1532
1533        let loaded = load(&VelaSource::VelaRepo(dir)).unwrap();
1534        assert_eq!(loaded.review_events.len(), 1);
1535        assert!(matches!(
1536            loaded.review_events[0].action,
1537            ReviewAction::Qualified { .. }
1538        ));
1539        assert_eq!(loaded.review_events[0].status.as_deref(), Some("accepted"));
1540    }
1541
1542    // ── load_from_path convenience ──────────────────────────────────
1543
1544    #[test]
1545    fn load_from_path_json() {
1546        let tmp = TempDir::new().unwrap();
1547        let path = tmp.path().join("convenience.json");
1548
1549        let original = make_project("convenience", vec![make_finding("vf_c1", 0.8, "mechanism")]);
1550        let json = serde_json::to_string_pretty(&original).unwrap();
1551        std::fs::write(&path, json).unwrap();
1552
1553        let loaded = load_from_path(&path).unwrap();
1554        assert_eq!(loaded.project.name, "convenience");
1555        assert_eq!(loaded.findings.len(), 1);
1556    }
1557
1558    #[test]
1559    fn load_from_path_repo() {
1560        let tmp = TempDir::new().unwrap();
1561        let dir = tmp.path().join("conv-repo");
1562
1563        let original = make_project("conv-repo", vec![make_finding("vf_cr1", 0.8, "mechanism")]);
1564        init_repo(&dir, &original).unwrap();
1565
1566        let loaded = load_from_path(&dir).unwrap();
1567        assert_eq!(loaded.project.name, "conv-repo");
1568        assert_eq!(loaded.findings.len(), 1);
1569    }
1570
1571    #[test]
1572    fn load_from_path_packet_dir() {
1573        let tmp = TempDir::new().unwrap();
1574        let dir = tmp.path().join("packet-frontier");
1575
1576        let mut original = make_project(
1577            "packet-frontier",
1578            vec![make_finding("vf_pkt1", 0.81, "mechanism")],
1579        );
1580        original.review_events.push(ReviewEvent {
1581            id: "rev_pkt1".into(),
1582            workspace: Some("bbb".into()),
1583            finding_id: "vf_pkt1".into(),
1584            reviewer: "reviewer:test".into(),
1585            reviewed_at: "2026-01-01T00:00:00Z".into(),
1586            scope: Some("external".into()),
1587            status: Some("accepted".into()),
1588            action: ReviewAction::Approved,
1589            reason: "Imported from another lab".into(),
1590            evidence_considered: vec![],
1591            state_change: None,
1592        });
1593        original.stats.review_event_count = original.review_events.len();
1594        crate::export::export_packet(&original, &dir).unwrap();
1595
1596        let loaded = load_from_path(&dir).unwrap();
1597        assert_eq!(loaded.project.name, "packet-frontier");
1598        assert_eq!(loaded.findings.len(), 1);
1599        assert_eq!(loaded.review_events.len(), 1);
1600        assert_eq!(loaded.stats.review_event_count, 1);
1601    }
1602
1603    // ── project file -> repo -> project file roundtrip ────────────
1604
1605    #[test]
1606    fn full_format_roundtrip() {
1607        let tmp = TempDir::new().unwrap();
1608
1609        // Create a project with findings and links
1610        let mut f1 = make_finding("vf_rt1", 0.85, "mechanism");
1611        f1.add_link("vf_rt2", "extends", "shared protein");
1612        let f2 = make_finding("vf_rt2", 0.72, "therapeutic");
1613
1614        let original = make_project("full-roundtrip", vec![f1, f2]);
1615
1616        // Save as JSON
1617        let json_path = tmp.path().join("original.json");
1618        save(&VelaSource::ProjectFile(json_path.clone()), &original).unwrap();
1619
1620        // Load from JSON
1621        let from_json = load(&VelaSource::ProjectFile(json_path)).unwrap();
1622
1623        // Save as repo
1624        let repo_dir = tmp.path().join("repo");
1625        init_repo(&repo_dir, &from_json).unwrap();
1626
1627        // Load from repo
1628        let from_repo = load(&VelaSource::VelaRepo(repo_dir)).unwrap();
1629
1630        // Verify structural equivalence
1631        assert_eq!(from_repo.findings.len(), from_json.findings.len());
1632        assert_eq!(from_repo.project.name, from_json.project.name);
1633
1634        let rt1 = from_repo
1635            .findings
1636            .iter()
1637            .find(|f| f.id == "vf_rt1")
1638            .unwrap();
1639        assert_eq!(rt1.links.len(), 1);
1640        assert_eq!(rt1.links[0].target, "vf_rt2");
1641        assert_eq!(rt1.links[0].link_type, "extends");
1642    }
1643}