1use std::fs;
48use std::path::{Path, PathBuf};
49
50use chrono::Utc;
51use serde::{Deserialize, Serialize};
52use sha2::{Digest, Sha256};
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct ConstellationManifest {
58 pub schema: String,
60 pub id: String,
62 pub name: String,
64 #[serde(default)]
67 pub scope_note: Option<String>,
68 pub composing_atlases: Vec<ConstellationAtlasRef>,
70 #[serde(default)]
73 pub cross_atlas_bridges: Vec<String>,
74 #[serde(default)]
76 pub maintainers: Vec<ConstellationMaintainer>,
77 pub created_at: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct ConstellationAtlasRef {
83 pub vat_id: String,
85 pub name: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub locator: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub role: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct ConstellationMaintainer {
97 pub actor_id: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub role: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ConstellationSnapshot {
105 pub schema: String,
106 pub constellation_id: String,
107 pub constellation_name: String,
108 pub generated_at: String,
109 pub atlas_count: usize,
110 pub total_frontiers: usize,
111 pub total_findings: usize,
112 pub total_accepted_core: usize,
113 pub total_events: usize,
114 pub total_bridges: usize,
115 pub cross_atlas_bridges: usize,
116 pub atlases: Vec<ConstellationAtlasSummary>,
117 pub composition_hash: String,
118 #[serde(default)]
121 pub released_diff_pack_count: usize,
122 #[serde(default)]
123 pub verdict_conflict_count: usize,
124 #[serde(default)]
125 pub pending_verdict_count: usize,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ConstellationAtlasSummary {
130 pub vat_id: String,
131 pub name: String,
132 pub frontiers: usize,
133 pub findings: usize,
134 pub accepted_core: usize,
135 pub events: usize,
136 pub bridges: usize,
137 pub role: Option<String>,
138}
139
140pub fn init_constellation(
144 constellations_root: &Path,
145 name: &str,
146 scope_note: Option<&str>,
147 atlas_dirs: &[PathBuf],
148) -> Result<(PathBuf, ConstellationManifest), String> {
149 if atlas_dirs.is_empty() {
150 return Err("init_constellation: at least one Atlas dir is required".to_string());
151 }
152 let dir_name = sanitize_name(name);
153 let dir = constellations_root.join(&dir_name);
154 fs::create_dir_all(&dir)
155 .map_err(|e| format!("create constellation dir {}: {e}", dir.display()))?;
156
157 let mut composing = Vec::with_capacity(atlas_dirs.len());
158 for ad in atlas_dirs {
159 let manifest_path = ad.join("manifest.yaml");
160 let yaml = fs::read_to_string(&manifest_path)
161 .map_err(|e| format!("read atlas manifest {}: {e}", manifest_path.display()))?;
162 let atlas_manifest: vela_atlas::AtlasManifest =
163 serde_yaml::from_str(&yaml).map_err(|e| format!("parse atlas manifest: {e}"))?;
164 composing.push(ConstellationAtlasRef {
165 vat_id: atlas_manifest.id.clone(),
166 name: atlas_manifest.name.clone(),
167 locator: Some(format!("file://{}", manifest_path.display())),
168 role: None,
169 });
170 }
171
172 let id = constellation_id_from_manifest(name, &composing);
173 let manifest = ConstellationManifest {
174 schema: "vela.constellation_manifest.v0.1".to_string(),
175 id,
176 name: name.to_string(),
177 scope_note: scope_note.map(String::from),
178 composing_atlases: composing,
179 cross_atlas_bridges: Vec::new(),
180 maintainers: Vec::new(),
181 created_at: Utc::now().to_rfc3339(),
182 };
183 let manifest_path = dir.join("manifest.yaml");
184 let yaml = serde_yaml::to_string(&manifest).map_err(|e| format!("serialize manifest: {e}"))?;
185 fs::write(&manifest_path, yaml).map_err(|e| format!("write manifest: {e}"))?;
186 Ok((manifest_path, manifest))
187}
188
189pub fn materialize_constellation(
195 constellation_dir: &Path,
196) -> Result<(PathBuf, ConstellationSnapshot), String> {
197 let manifest_path = constellation_dir.join("manifest.yaml");
198 let yaml = fs::read_to_string(&manifest_path)
199 .map_err(|e| format!("read manifest {}: {e}", manifest_path.display()))?;
200 let mut manifest: ConstellationManifest =
201 serde_yaml::from_str(&yaml).map_err(|e| format!("parse manifest: {e}"))?;
202
203 let cross_added = sync_cross_atlas_bridges(&mut manifest)?;
209 if cross_added > 0 {
210 let yaml_out = serde_yaml::to_string(&manifest)
211 .map_err(|e| format!("re-serialize manifest after cross-bridge sync: {e}"))?;
212 fs::write(&manifest_path, yaml_out)
213 .map_err(|e| format!("write manifest after cross-bridge sync: {e}"))?;
214 }
215
216 let mut atlas_summaries = Vec::with_capacity(manifest.composing_atlases.len());
217 let mut total_frontiers = 0usize;
218 let mut total_findings = 0usize;
219 let mut total_accepted_core = 0usize;
220 let mut total_events = 0usize;
221 let mut total_bridges = 0usize;
222 let mut released_diff_pack_count = 0usize;
224 let mut verdict_conflict_count = 0usize;
225 let mut pending_verdict_count = 0usize;
226 for ar in &manifest.composing_atlases {
227 let locator = ar
228 .locator
229 .as_deref()
230 .ok_or_else(|| format!("atlas {} has no locator", ar.name))?;
231 let manifest_path = locator
232 .strip_prefix("file://")
233 .map(PathBuf::from)
234 .ok_or_else(|| format!("atlas locator must be a file:// URL, got '{locator}'"))?;
235 let atlas_dir = manifest_path.parent().ok_or_else(|| {
236 format!(
237 "atlas manifest path has no parent: {}",
238 manifest_path.display()
239 )
240 })?;
241
242 let (_, atlas_snapshot) = vela_atlas::materialize_atlas(atlas_dir)
247 .map_err(|e| format!("materialize atlas {}: {e}", atlas_dir.display()))?;
248
249 total_frontiers += atlas_snapshot.frontier_count;
250 total_findings += atlas_snapshot.total_findings;
251 total_accepted_core += atlas_snapshot.accepted_core_findings;
252 total_events += atlas_snapshot.total_events;
253 total_bridges += atlas_snapshot.bridge_count;
254 released_diff_pack_count += atlas_snapshot.released_diff_pack_count;
255 verdict_conflict_count += atlas_snapshot.verdict_conflict_count;
256 pending_verdict_count += atlas_snapshot.pending_verdict_count;
257
258 atlas_summaries.push(ConstellationAtlasSummary {
259 vat_id: atlas_snapshot.atlas_id,
260 name: atlas_snapshot.atlas_name,
261 frontiers: atlas_snapshot.frontier_count,
262 findings: atlas_snapshot.total_findings,
263 accepted_core: atlas_snapshot.accepted_core_findings,
264 events: atlas_snapshot.total_events,
265 bridges: atlas_snapshot.bridge_count,
266 role: ar.role.clone(),
267 });
268 }
269
270 let snapshot = ConstellationSnapshot {
271 schema: "vela.constellation_snapshot.v0.1".to_string(),
272 constellation_id: manifest.id.clone(),
273 constellation_name: manifest.name.clone(),
274 generated_at: Utc::now().to_rfc3339(),
275 atlas_count: manifest.composing_atlases.len(),
276 total_frontiers,
277 total_findings,
278 total_accepted_core,
279 total_events,
280 total_bridges,
281 cross_atlas_bridges: manifest.cross_atlas_bridges.len(),
282 atlases: atlas_summaries,
283 composition_hash: composition_hash(&manifest),
284 released_diff_pack_count,
285 verdict_conflict_count,
286 pending_verdict_count,
287 };
288
289 let snapshot_path = constellation_dir.join("snapshot.json");
290 let json =
291 serde_json::to_string_pretty(&snapshot).map_err(|e| format!("serialize snapshot: {e}"))?;
292 fs::write(&snapshot_path, format!("{json}\n")).map_err(|e| format!("write snapshot: {e}"))?;
293
294 let html = render_constellation_html(&manifest, &snapshot);
295 fs::write(constellation_dir.join("index.html"), html)
296 .map_err(|e| format!("write constellation index.html: {e}"))?;
297
298 Ok((snapshot_path, snapshot))
299}
300
301fn sync_cross_atlas_bridges(manifest: &mut ConstellationManifest) -> Result<usize, String> {
311 use serde_json::Value;
312 use std::collections::{HashMap, HashSet};
313
314 let mut vfr_to_vat: HashMap<String, String> = HashMap::new();
317 let mut bridge_dirs: Vec<PathBuf> = Vec::new();
318
319 for ar in &manifest.composing_atlases {
320 let Some(locator) = ar.locator.as_deref() else {
321 continue;
322 };
323 let Some(atlas_manifest_path) = locator.strip_prefix("file://") else {
324 continue;
325 };
326 let atlas_manifest_path = PathBuf::from(atlas_manifest_path);
327 let yaml = match fs::read_to_string(&atlas_manifest_path) {
328 Ok(y) => y,
329 Err(_) => continue,
330 };
331 let atlas_manifest: vela_atlas::AtlasManifest = match serde_yaml::from_str(&yaml) {
332 Ok(m) => m,
333 Err(_) => continue,
334 };
335 let vat_id = atlas_manifest.id.clone();
336 for fr in &atlas_manifest.composing_frontiers {
337 vfr_to_vat
338 .entry(fr.vfr_id.clone())
339 .or_insert_with(|| vat_id.clone());
340 let Some(loc) = fr.locator.as_deref() else {
342 continue;
343 };
344 let Some(frontier_path) = loc.strip_prefix("file://") else {
345 continue;
346 };
347 let p = PathBuf::from(frontier_path);
348 if p.is_dir() {
349 bridge_dirs.push(p.join(".vela").join("bridges"));
350 } else if let Some(parent) = p.parent() {
351 bridge_dirs.push(parent.join(".vela").join("bridges"));
352 }
353 }
354 }
355
356 if vfr_to_vat.is_empty() {
357 return Ok(0);
358 }
359
360 let already: HashSet<String> = manifest.cross_atlas_bridges.iter().cloned().collect();
361 let mut seen_this_run: HashSet<String> = HashSet::new();
362 let mut added = 0usize;
363
364 bridge_dirs.sort();
366 bridge_dirs.dedup();
367
368 for dir in &bridge_dirs {
369 if !dir.is_dir() {
370 continue;
371 }
372 let entries = match fs::read_dir(dir) {
373 Ok(e) => e,
374 Err(_) => continue,
375 };
376 for entry in entries.flatten() {
377 let path = entry.path();
378 if path.extension().and_then(|s| s.to_str()) != Some("json") {
379 continue;
380 }
381 let Ok(text) = fs::read_to_string(&path) else {
382 continue;
383 };
384 let Ok(bridge): Result<Value, _> = serde_json::from_str(&text) else {
385 continue;
386 };
387 let id = bridge
388 .get("id")
389 .and_then(Value::as_str)
390 .unwrap_or("")
391 .to_string();
392 if id.is_empty() || already.contains(&id) || seen_this_run.contains(&id) {
393 continue;
394 }
395 let status = bridge.get("status").and_then(Value::as_str).unwrap_or("");
396 if !matches!(status, "confirmed" | "Confirmed") {
397 continue;
398 }
399 let endpoints: Vec<String> = bridge
400 .get("frontier_ids")
401 .and_then(Value::as_array)
402 .map(|arr| {
403 arr.iter()
404 .filter_map(Value::as_str)
405 .map(str::to_string)
406 .collect()
407 })
408 .unwrap_or_default();
409 if endpoints.len() < 2 {
410 continue;
411 }
412 let mats: Vec<&String> = endpoints.iter().filter_map(|e| vfr_to_vat.get(e)).collect();
416 if mats.len() != endpoints.len() {
417 continue;
420 }
421 let distinct: HashSet<&&String> = mats.iter().collect();
422 if distinct.len() < 2 {
423 continue;
424 }
425 seen_this_run.insert(id.clone());
426 manifest.cross_atlas_bridges.push(id);
427 added += 1;
428 }
429 }
430
431 Ok(added)
432}
433
434fn composition_hash(manifest: &ConstellationManifest) -> String {
435 let mut h = Sha256::new();
436 h.update(manifest.id.as_bytes());
437 h.update(b"|");
438 for ar in &manifest.composing_atlases {
439 h.update(ar.vat_id.as_bytes());
440 h.update(b",");
441 }
442 h.update(b"|cross_bridges|");
443 for vbr in &manifest.cross_atlas_bridges {
444 h.update(vbr.as_bytes());
445 h.update(b",");
446 }
447 format!("sha256:{}", hex::encode(h.finalize()))
448}
449
450fn constellation_id_from_manifest(name: &str, composing: &[ConstellationAtlasRef]) -> String {
451 let mut h = Sha256::new();
452 h.update(name.as_bytes());
453 h.update(b"|");
454 for ar in composing {
455 h.update(ar.vat_id.as_bytes());
456 h.update(b",");
457 }
458 let digest = h.finalize();
459 let short = hex::encode(&digest[..8]);
460 format!("vco_{short}")
461}
462
463fn sanitize_name(name: &str) -> String {
464 name.chars()
465 .map(|c| {
466 if c.is_ascii_alphanumeric() || c == '-' {
467 c.to_ascii_lowercase()
468 } else {
469 '-'
470 }
471 })
472 .collect()
473}
474
475fn render_constellation_html(
476 manifest: &ConstellationManifest,
477 snapshot: &ConstellationSnapshot,
478) -> String {
479 let mut atlases_html = String::new();
480 for a in &snapshot.atlases {
481 let role = a.role.as_deref().unwrap_or("");
482 let role_html = if role.is_empty() {
483 String::new()
484 } else {
485 format!(" <span class=\"role\">{role}</span>")
486 };
487 atlases_html.push_str(&format!(
488 "<li><strong>{name}</strong>{role_html}<br/><code>{vat}</code> · {frontiers} frontiers, {findings} findings ({accepted} accepted-core), {events} events, {bridges} bridges</li>",
489 name = html_escape(&a.name),
490 vat = html_escape(&a.vat_id),
491 frontiers = a.frontiers,
492 findings = a.findings,
493 accepted = a.accepted_core,
494 events = a.events,
495 bridges = a.bridges,
496 ));
497 }
498 let scope = match manifest.scope_note.as_deref() {
499 Some(text) if !text.is_empty() => {
500 format!("<p class=\"scope\">{}</p>", html_escape(text))
501 }
502 _ => String::new(),
503 };
504 format!(
505 r#"<!doctype html>
506<html lang="en">
507<head>
508<meta charset="utf-8">
509<title>{name} · Vela Constellation</title>
510<meta name="viewport" content="width=device-width,initial-scale=1">
511<style>
512 body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1.4rem; color: #222; line-height: 1.55; }}
513 h1 {{ font-size: 1.4rem; margin: 0 0 0.4rem 0; }}
514 h2 {{ font-size: 1.05rem; margin: 1.6rem 0 0.5rem 0; border-bottom: 1px solid #eee; padding-bottom: 0.2rem; }}
515 .meta {{ color: #666; font-size: 0.92em; }}
516 .scope {{ background: #f7f5f0; border-left: 3px solid #4a7c59; padding: 0.6rem 0.9rem; margin: 0.8rem 0; }}
517 code {{ background: #f5f2ec; padding: 0.05em 0.35em; border-radius: 2px; font-size: 0.9em; }}
518 ul {{ padding-left: 1.4rem; }}
519 li {{ margin: 0.4rem 0; }}
520 .role {{ color: #888; font-size: 0.85em; font-style: italic; }}
521 table {{ border-collapse: collapse; margin: 0.6rem 0; }}
522 td {{ padding: 0.2rem 0.8rem 0.2rem 0; vertical-align: top; }}
523 td.k {{ color: #666; }}
524 footer {{ margin-top: 2rem; color: #999; font-size: 0.85em; }}
525</style>
526</head>
527<body>
528<h1>{name}</h1>
529<div class="meta">{vco}</div>
530{scope}
531
532<h2>Composition</h2>
533<table>
534<tr><td class="k">atlases</td><td>{atlases}</td></tr>
535<tr><td class="k">total frontiers</td><td>{frontiers}</td></tr>
536<tr><td class="k">total findings</td><td>{findings}</td></tr>
537<tr><td class="k">accepted-core findings</td><td>{accepted}</td></tr>
538<tr><td class="k">total events</td><td>{events}</td></tr>
539<tr><td class="k">total bridges (manifest)</td><td>{bridges}</td></tr>
540<tr><td class="k">cross-Atlas bridges</td><td>{cross}</td></tr>
541<tr><td class="k">composition hash</td><td><code>{hash}</code></td></tr>
542<tr><td class="k">generated at</td><td>{ts}</td></tr>
543</table>
544
545<h2>Composing Atlases</h2>
546<ul>
547{atlases_html}
548</ul>
549
550<footer>
551Vela Constellation v0.81 · <a href="https://github.com/vela-science/vela">github.com/vela-science/vela</a>
552</footer>
553</body>
554</html>
555"#,
556 name = html_escape(&manifest.name),
557 vco = html_escape(&manifest.id),
558 scope = scope,
559 atlases = snapshot.atlas_count,
560 frontiers = snapshot.total_frontiers,
561 findings = snapshot.total_findings,
562 accepted = snapshot.total_accepted_core,
563 events = snapshot.total_events,
564 bridges = snapshot.total_bridges,
565 cross = snapshot.cross_atlas_bridges,
566 hash = html_escape(&snapshot.composition_hash),
567 ts = html_escape(&snapshot.generated_at),
568 atlases_html = atlases_html,
569 )
570}
571
572fn html_escape(s: &str) -> String {
573 s.replace('&', "&")
574 .replace('<', "<")
575 .replace('>', ">")
576 .replace('"', """)
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use tempfile::tempdir;
583
584 #[test]
585 fn constellation_id_is_content_addressed() {
586 let composing = vec![
587 ConstellationAtlasRef {
588 vat_id: "vat_aaaa".to_string(),
589 name: "a".to_string(),
590 locator: None,
591 role: None,
592 },
593 ConstellationAtlasRef {
594 vat_id: "vat_bbbb".to_string(),
595 name: "b".to_string(),
596 locator: None,
597 role: None,
598 },
599 ];
600 let id1 = constellation_id_from_manifest("Demo", &composing);
601 let id2 = constellation_id_from_manifest("Demo", &composing);
602 assert_eq!(id1, id2);
603 let id3 = constellation_id_from_manifest("Other", &composing);
604 assert_ne!(id1, id3);
605 }
606
607 #[test]
608 fn init_constellation_writes_manifest() {
609 let dir = tempdir().expect("tempdir");
610 let constellations = dir.path().join("constellations");
611
612 let atlas_dir = dir.path().join("atlas-a");
615 fs::create_dir_all(&atlas_dir).unwrap();
616 let atlas_manifest = vela_atlas::AtlasManifest {
617 schema: "vela.atlas_manifest.v0.1".to_string(),
618 id: "vat_test".to_string(),
619 name: "Test Atlas".to_string(),
620 domain: "demo".to_string(),
621 scope_note: None,
622 composing_frontiers: vec![],
623 bridges: vec![],
624 maintainers: vec![],
625 review_policy_locator: None,
626 created_at: Utc::now().to_rfc3339(),
627 };
628 let yaml = serde_yaml::to_string(&atlas_manifest).unwrap();
629 fs::write(atlas_dir.join("manifest.yaml"), yaml).unwrap();
630
631 let (manifest_path, manifest) = init_constellation(
632 &constellations,
633 "demo-constellation",
634 Some("test scope"),
635 &[atlas_dir],
636 )
637 .expect("init");
638
639 assert!(manifest_path.is_file());
640 assert!(manifest.id.starts_with("vco_"));
641 assert_eq!(manifest.composing_atlases.len(), 1);
642 assert_eq!(manifest.composing_atlases[0].vat_id, "vat_test");
643 }
644}