1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
2use std::fmt::{Display, Formatter};
3use std::str::FromStr;
4
5use anyhow::{anyhow, bail, Result};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9pub const SPORE_SCHEMA: &str = "https://cmn.dev/schemas/v1/spore.json";
10pub const SPORE_CORE_SCHEMA: &str = "https://cmn.dev/schemas/v1/spore-core.json";
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub enum BondRelation {
14 SpawnedFrom,
15 AbsorbedFrom,
16 DependsOn,
17 Follows,
18 Extends,
19 Other(String),
20}
21
22impl BondRelation {
23 pub fn as_str(&self) -> &str {
24 match self {
25 Self::SpawnedFrom => "spawned_from",
26 Self::AbsorbedFrom => "absorbed_from",
27 Self::DependsOn => "depends_on",
28 Self::Follows => "follows",
29 Self::Extends => "extends",
30 Self::Other(value) => value.as_str(),
31 }
32 }
33
34 pub fn is_historical(&self) -> bool {
35 matches!(self, Self::SpawnedFrom | Self::AbsorbedFrom)
36 }
37
38 pub fn participates_in_bond_updates(&self) -> bool {
39 matches!(self, Self::DependsOn | Self::Follows | Self::Extends)
40 }
41
42 pub fn is_spawned_from(&self) -> bool {
43 matches!(self, Self::SpawnedFrom)
44 }
45
46 pub fn is_absorbed_from(&self) -> bool {
47 matches!(self, Self::AbsorbedFrom)
48 }
49
50 pub fn is_excluded_from_bond_fetch(&self) -> bool {
51 self.is_spawned_from() || self.is_absorbed_from()
52 }
53}
54
55impl Display for BondRelation {
56 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
57 f.write_str(self.as_str())
58 }
59}
60
61impl FromStr for BondRelation {
62 type Err = anyhow::Error;
63
64 fn from_str(value: &str) -> anyhow::Result<Self> {
65 if value.is_empty() {
66 bail!("Bond relation must not be empty");
67 }
68
69 Ok(match value {
70 "spawned_from" => Self::SpawnedFrom,
71 "absorbed_from" => Self::AbsorbedFrom,
72 "depends_on" => Self::DependsOn,
73 "follows" => Self::Follows,
74 "extends" => Self::Extends,
75 other => Self::Other(other.to_string()),
76 })
77 }
78}
79
80impl Serialize for BondRelation {
81 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
82 where
83 S: serde::Serializer,
84 {
85 serializer.serialize_str(self.as_str())
86 }
87}
88
89impl<'de> Deserialize<'de> for BondRelation {
90 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
91 where
92 D: serde::Deserializer<'de>,
93 {
94 let value = String::deserialize(deserializer)?;
95 Self::from_str(&value).map_err(serde::de::Error::custom)
96 }
97}
98
99#[derive(Serialize, Deserialize, Debug, Clone)]
101pub struct Spore {
102 #[serde(rename = "$schema")]
103 pub schema: String,
104 pub capsule: SporeCapsule,
105 pub capsule_signature: String,
106}
107
108#[derive(Serialize, Deserialize, Debug, Clone)]
110pub struct SporeCapsule {
111 pub uri: String,
112 pub core: SporeCore,
113 pub core_signature: String,
114 pub dist: Vec<SporeDist>,
115}
116
117#[derive(Serialize, Deserialize, Debug, Clone)]
119pub struct SporeCore {
120 #[serde(default, skip_serializing_if = "String::is_empty")]
121 pub id: String,
122 pub name: String,
123 #[serde(default, skip_serializing_if = "String::is_empty")]
124 pub version: String,
125 pub domain: String,
126 #[serde(default, skip_serializing_if = "String::is_empty")]
127 pub key: String,
128 pub synopsis: String,
129 pub intent: Vec<String>,
130 pub license: String,
131 #[serde(default)]
132 pub mutations: Vec<String>,
133 #[serde(default)]
134 pub size_bytes: u64,
135 #[serde(default)]
136 pub updated_at_epoch_ms: u64,
137 #[serde(default)]
138 pub bonds: Vec<SporeBond>,
139 pub tree: SporeTree,
140}
141
142#[derive(Serialize, Deserialize, Debug, Clone)]
144pub struct SporeTree {
145 #[serde(default = "SporeTree::default_algorithm")]
146 pub algorithm: String,
147 #[serde(default)]
148 pub exclude_names: Vec<String>,
149 #[serde(default)]
150 pub follow_rules: Vec<String>,
151}
152
153#[derive(Serialize, Deserialize, Debug, Clone)]
155pub struct SporeCoreDocument {
156 #[serde(rename = "$schema")]
157 pub schema: String,
158 #[serde(flatten)]
159 pub core: SporeCore,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Hash)]
164pub enum DistKind {
165 Archive,
166 Git,
167 Ipfs,
168 Other(String),
169}
170
171impl DistKind {
172 pub fn as_str(&self) -> &str {
173 match self {
174 Self::Archive => "archive",
175 Self::Git => "git",
176 Self::Ipfs => "ipfs",
177 Self::Other(value) => value.as_str(),
178 }
179 }
180}
181
182impl Display for DistKind {
183 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
184 f.write_str(self.as_str())
185 }
186}
187
188impl FromStr for DistKind {
189 type Err = anyhow::Error;
190
191 fn from_str(value: &str) -> anyhow::Result<Self> {
192 Ok(match value {
193 "archive" => Self::Archive,
194 "git" => Self::Git,
195 "ipfs" => Self::Ipfs,
196 other => Self::Other(other.to_string()),
197 })
198 }
199}
200
201impl Serialize for DistKind {
202 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
203 where
204 S: serde::Serializer,
205 {
206 serializer.serialize_str(self.as_str())
207 }
208}
209
210impl<'de> Deserialize<'de> for DistKind {
211 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
212 where
213 D: serde::Deserializer<'de>,
214 {
215 let value = String::deserialize(deserializer)?;
216 Self::from_str(&value).map_err(serde::de::Error::custom)
217 }
218}
219
220#[derive(Serialize, Deserialize, Debug, Clone)]
222pub struct SporeDist {
223 #[serde(rename = "type")]
224 pub kind: DistKind,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub filename: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub url: Option<String>,
229 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
230 pub git_ref: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub cid: Option<String>,
233 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
234 pub extra: BTreeMap<String, Value>,
235}
236
237impl SporeDist {
238 pub fn is_archive(&self) -> bool {
239 self.kind == DistKind::Archive
240 }
241
242 pub fn is_git(&self) -> bool {
243 self.kind == DistKind::Git
244 }
245
246 pub fn git_url(&self) -> Option<&str> {
247 self.is_git().then_some(self.url.as_deref()).flatten()
248 }
249
250 pub fn git_ref(&self) -> Option<&str> {
251 self.is_git().then_some(self.git_ref.as_deref()).flatten()
252 }
253}
254
255impl Default for SporeTree {
256 fn default() -> Self {
257 Self {
258 algorithm: Self::default_algorithm(),
259 exclude_names: vec![],
260 follow_rules: vec![],
261 }
262 }
263}
264
265impl SporeTree {
266 fn default_algorithm() -> String {
267 "blob_tree_blake3_nfc".to_string()
268 }
269
270 pub fn compute_hash(&self, entries: &[crate::tree::TreeEntry]) -> anyhow::Result<String> {
271 crate::tree::compute_tree_hash_from_entries(entries, self)
272 }
273
274 pub fn compute_hash_and_size(
275 &self,
276 entries: &[crate::tree::TreeEntry],
277 ) -> anyhow::Result<(String, u64)> {
278 crate::tree::compute_tree_hash_and_size_from_entries(entries, self)
279 }
280}
281
282#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
284pub struct SporeBond {
285 pub relation: BondRelation,
286 pub uri: String,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub id: Option<String>,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub reason: Option<String>,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub with: Option<Value>,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "snake_case")]
297pub enum BondTraversalDirection {
298 Outbound,
299 Inbound,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct BondGraphNode {
304 pub uri: String,
305 #[serde(default)]
306 pub bonds: Vec<SporeBond>,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct BondTraversalQuery {
311 pub start: String,
312 pub direction: BondTraversalDirection,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub relation: Option<BondRelation>,
315 #[serde(default = "BondTraversalQuery::default_max_depth")]
316 pub max_depth: u32,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct BondTraversalHit {
321 pub uri: String,
322 pub depth: u32,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326pub struct BondTraversalResult {
327 pub hits: Vec<BondTraversalHit>,
328 pub max_depth_reached: bool,
329}
330
331pub const MAX_BOND_DEPTH: u32 = 64;
333
334impl BondTraversalQuery {
335 fn default_max_depth() -> u32 {
336 1
337 }
338}
339
340pub fn traverse_bond_graph(
341 graph: &[BondGraphNode],
342 query: &BondTraversalQuery,
343) -> BondTraversalResult {
344 let mut graph_by_uri = HashMap::with_capacity(graph.len());
345 for node in graph {
346 graph_by_uri.insert(node.uri.as_str(), node);
347 }
348
349 let mut hits = Vec::new();
350 let mut visited = HashSet::new();
351 let mut queue = VecDeque::new();
352 let mut max_depth_reached = false;
353
354 visited.insert(query.start.clone());
355 queue.push_back((query.start.as_str(), 0_u32));
356
357 while let Some((current_uri, depth)) = queue.pop_front() {
358 let next_edges: Vec<(&str, &BondRelation)> = match query.direction {
359 BondTraversalDirection::Outbound => graph_by_uri
360 .get(current_uri)
361 .into_iter()
362 .flat_map(|node| node.bonds.iter())
363 .filter(|bond| {
364 query
365 .relation
366 .as_ref()
367 .map(|relation| bond.relation == *relation)
368 .unwrap_or(true)
369 })
370 .map(|bond| (bond.uri.as_str(), &bond.relation))
371 .collect(),
372 BondTraversalDirection::Inbound => graph
373 .iter()
374 .flat_map(|node| {
375 node.bonds
376 .iter()
377 .filter(move |bond| bond.uri == current_uri)
378 .map(move |bond| (node.uri.as_str(), &bond.relation))
379 })
380 .filter(|(_, relation)| {
381 query
382 .relation
383 .as_ref()
384 .map(|expected| *relation == expected)
385 .unwrap_or(true)
386 })
387 .collect(),
388 };
389
390 for (next_uri, _) in next_edges {
391 let next_depth = depth.saturating_add(1);
392 if next_depth > query.max_depth {
393 max_depth_reached = true;
394 continue;
395 }
396 if !visited.insert(next_uri.to_string()) {
397 continue;
398 }
399
400 hits.push(BondTraversalHit {
401 uri: next_uri.to_string(),
402 depth: next_depth,
403 });
404 queue.push_back((next_uri, next_depth));
405 }
406 }
407
408 BondTraversalResult {
409 hits,
410 max_depth_reached,
411 }
412}
413
414impl Spore {
415 pub fn new(
417 domain: &str,
418 name: &str,
419 synopsis: &str,
420 intent: Vec<String>,
421 license: &str,
422 ) -> Self {
423 Self {
424 schema: SPORE_SCHEMA.to_string(),
425 capsule: SporeCapsule {
426 uri: String::new(),
427 core: SporeCore {
428 id: String::new(),
429 version: String::new(),
430 name: name.to_string(),
431 domain: domain.to_string(),
432 key: String::new(),
433 synopsis: synopsis.to_string(),
434 intent,
435 license: license.to_string(),
436 mutations: vec![],
437 size_bytes: 0,
438 bonds: vec![],
439 tree: SporeTree::default(),
440 updated_at_epoch_ms: 0,
441 },
442 core_signature: String::new(),
443 dist: vec![],
444 },
445 capsule_signature: String::new(),
446 }
447 }
448
449 pub fn uri(&self) -> &str {
450 &self.capsule.uri
451 }
452
453 pub fn author_domain(&self) -> &str {
454 &self.capsule.core.domain
455 }
456
457 pub fn timestamp_ms(&self) -> u64 {
458 self.capsule.core.updated_at_epoch_ms
459 }
460
461 pub fn embedded_core_key(&self) -> Option<&str> {
462 let key = self.capsule.core.key.as_str();
463 (!key.is_empty()).then_some(key)
464 }
465
466 pub fn tree(&self) -> &SporeTree {
467 &self.capsule.core.tree
468 }
469
470 pub fn distributions(&self) -> &[SporeDist] {
471 &self.capsule.dist
472 }
473
474 pub fn followed_strain_uris(&self) -> Vec<&str> {
475 self.capsule
476 .core
477 .bonds
478 .iter()
479 .filter(|bond| bond.relation == BondRelation::Follows)
480 .map(|bond| bond.uri.as_str())
481 .collect()
482 }
483
484 pub fn follows_uri(&self, uri: &str) -> bool {
485 self.capsule
486 .core
487 .bonds
488 .iter()
489 .any(|bond| bond.relation == BondRelation::Follows && bond.uri == uri)
490 }
491
492 pub fn follows_all(&self, required_uris: &[&str]) -> bool {
493 required_uris.iter().all(|uri| self.follows_uri(uri))
494 }
495
496 pub fn extended_strain_uris(&self) -> Vec<&str> {
497 self.capsule
498 .core
499 .bonds
500 .iter()
501 .filter(|bond| bond.relation == BondRelation::Extends)
502 .map(|bond| bond.uri.as_str())
503 .collect()
504 }
505
506 pub fn extends_uri(&self, uri: &str) -> bool {
507 self.capsule
508 .core
509 .bonds
510 .iter()
511 .any(|bond| bond.relation == BondRelation::Extends && bond.uri == uri)
512 }
513
514 pub fn extends_all(&self, required_uris: &[&str]) -> bool {
515 required_uris.iter().all(|uri| self.extends_uri(uri))
516 }
517
518 pub fn is_strain_definition(&self, accepted_root_lineage_uris: &[&str]) -> bool {
519 accepted_root_lineage_uris
520 .iter()
521 .any(|uri| self.extends_uri(uri))
522 }
523
524 pub fn spawned_from_uri(&self) -> Option<&str> {
525 self.capsule
526 .core
527 .bonds
528 .iter()
529 .find(|bond| bond.relation.is_spawned_from())
530 .map(|bond| bond.uri.as_str())
531 }
532
533 pub fn spawned_from_hash(&self) -> Option<String> {
534 crate::uri::parse_uri(self.spawned_from_uri()?)
535 .ok()
536 .and_then(|uri| uri.hash)
537 }
538
539 pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
540 crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
541 }
542
543 pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
544 crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
545 }
546
547 pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
548 self.verify_core_signature(author_key)?;
549 self.verify_capsule_signature(host_key)
550 }
551
552 pub fn computed_uri_hash_from_tree_hash(&self, tree_hash: &str) -> Result<String> {
553 crate::crypto::hash::compute_tree_signed_core_hash(
554 tree_hash,
555 &self.capsule.core,
556 &self.capsule.core_signature,
557 )
558 }
559
560 pub fn verify_uri_hash_from_tree_hash(
561 &self,
562 expected_hash: &str,
563 tree_hash: &str,
564 ) -> Result<()> {
565 let actual_hash = self.computed_uri_hash_from_tree_hash(tree_hash)?;
566 super::verify_expected_uri_hash(&actual_hash, expected_hash)
567 }
568
569 pub fn verify_content_hash(
570 &self,
571 entries: &[crate::tree::TreeEntry],
572 expected_hash: &str,
573 ) -> Result<()> {
574 let tree_hash = self.tree().compute_hash(entries)?;
575 self.verify_uri_hash_from_tree_hash(expected_hash, &tree_hash)
576 }
577
578 pub fn verify_content_hash_and_size(
581 &self,
582 entries: &[crate::tree::TreeEntry],
583 expected_hash: &str,
584 ) -> Result<()> {
585 let (tree_hash, computed_size) = self.tree().compute_hash_and_size(entries)?;
586 self.verify_uri_hash_from_tree_hash(expected_hash, &tree_hash)?;
587 let declared = self.capsule.core.size_bytes;
588 if declared > 0 && computed_size != declared {
589 return Err(anyhow!(
590 "size_bytes mismatch: declared {} but computed {}",
591 declared,
592 computed_size
593 ));
594 }
595 Ok(())
596 }
597}
598
599impl SporeCoreDocument {
600 pub fn into_core(self) -> SporeCore {
601 self.core
602 }
603
604 pub fn core(&self) -> &SporeCore {
605 &self.core
606 }
607}
608
609#[cfg(test)]
610#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
611mod tests {
612
613 use super::*;
614 #[test]
615 fn test_spore_bond() {
616 let reference = SporeBond {
617 uri: "cmn://other.com/b3.def456".to_string(),
618 relation: BondRelation::DependsOn,
619 id: None,
620 reason: None,
621 with: None,
622 };
623
624 let json = serde_json::to_string(&reference).unwrap_or_default();
625 assert!(json.contains("\"uri\""));
626 assert!(json.contains("\"relation\""));
627 assert!(json.contains("depends_on"));
628 }
629
630 #[test]
631 fn test_spore_new() {
632 let spore = Spore::new(
633 "example.com",
634 "my-tool",
635 "A useful tool",
636 vec!["Initial release".to_string()],
637 "MIT",
638 );
639 assert_eq!(spore.schema, SPORE_SCHEMA);
640 assert_eq!(spore.capsule.core.name, "my-tool");
641 assert_eq!(spore.capsule.core.domain, "example.com");
642 assert_eq!(spore.capsule.core.synopsis, "A useful tool");
643 assert_eq!(spore.capsule.core.license, "MIT");
644 }
645
646 #[test]
647 fn test_spore_with_bonds() {
648 let mut spore = Spore::new(
649 "example.com",
650 "child-spore",
651 "A child spore",
652 vec!["v1.0".to_string()],
653 "MIT",
654 );
655 spore.capsule.core.bonds = vec![
656 SporeBond {
657 uri: "cmn://parent.com/b3.parent1".to_string(),
658 relation: BondRelation::SpawnedFrom,
659 id: None,
660 reason: None,
661 with: None,
662 },
663 SporeBond {
664 uri: "cmn://lib.com/b3.lib1".to_string(),
665 relation: BondRelation::DependsOn,
666 id: None,
667 reason: None,
668 with: None,
669 },
670 ];
671 spore.capsule.core_signature = "ed25519.abc".to_string();
672 spore.capsule_signature = "ed25519.def".to_string();
673 spore.capsule.uri = "cmn://example.com/b3.abc123".to_string();
674
675 let json = serde_json::to_string_pretty(&spore).unwrap_or_default();
676 assert!(json.contains("\"bonds\""));
677 assert!(json.contains("spawned_from"));
678 assert!(json.contains("parent.com"));
679
680 let parsed: Spore = serde_json::from_str(&json).unwrap();
681 assert_eq!(parsed.capsule.core.bonds.len(), 2);
682 assert_eq!(
683 parsed.capsule.core.bonds[0].relation,
684 BondRelation::SpawnedFrom
685 );
686 assert_eq!(
687 parsed.capsule.core.bonds[1].relation,
688 BondRelation::DependsOn
689 );
690 }
691
692 #[test]
693 fn test_spore_tree() {
694 let tree = SporeTree {
695 algorithm: "blob_tree_blake3_nfc".to_string(),
696 exclude_names: vec!["node_modules".to_string(), ".git".to_string()],
697 follow_rules: vec![".gitignore".to_string()],
698 };
699
700 let json = serde_json::to_string(&tree).unwrap_or_default();
701 assert!(json.contains("blob_tree_blake3_nfc"));
702 assert!(json.contains("node_modules"));
703 assert!(json.contains(".gitignore"));
704
705 let parsed: SporeTree = serde_json::from_str(&json).unwrap();
706 assert_eq!(parsed.algorithm, "blob_tree_blake3_nfc");
707 assert_eq!(parsed.exclude_names.len(), 2);
708 assert_eq!(parsed.follow_rules.len(), 1);
709 }
710
711 #[test]
712 fn test_spore_strain_helpers() {
713 let mut spore = Spore::new(
714 "example.com",
715 "strain-child",
716 "A strain child",
717 vec!["Initial release".to_string()],
718 "MIT",
719 );
720 spore.capsule.core.bonds = vec![
721 SporeBond {
722 uri: "cmn://service.dev/b3.service".to_string(),
723 relation: BondRelation::Follows,
724 id: None,
725 reason: None,
726 with: None,
727 },
728 SporeBond {
729 uri: "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2".to_string(),
730 relation: BondRelation::Extends,
731 id: None,
732 reason: None,
733 with: None,
734 },
735 SporeBond {
736 uri: "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W".to_string(),
737 relation: BondRelation::Extends,
738 id: None,
739 reason: None,
740 with: None,
741 },
742 ];
743
744 assert_eq!(
745 spore.followed_strain_uris(),
746 vec!["cmn://service.dev/b3.service"]
747 );
748 assert!(spore.follows_uri("cmn://service.dev/b3.service"));
749 assert!(spore.follows_all(&["cmn://service.dev/b3.service"]));
750 assert_eq!(
751 spore.extended_strain_uris(),
752 vec![
753 "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2",
754 "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W"
755 ]
756 );
757 assert!(
758 spore.extends_uri("cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W")
759 );
760 assert!(spore.extends_all(&[
761 "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2",
762 "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W"
763 ]));
764 assert!(spore
765 .is_strain_definition(&["cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2"]));
766 assert!(!spore.is_strain_definition(&[
767 "cmn://other-root.dev/b3.Bp7WKXh4Rxx2jyu5taj2aeMorH5YLT4R8DF5rWq7jZjq"
768 ]));
769 }
770
771 #[test]
772 fn test_traverse_bond_graph_outbound() {
773 let graph = vec![
774 BondGraphNode {
775 uri: "cmn://a.dev/b3.parent".to_string(),
776 bonds: vec![],
777 },
778 BondGraphNode {
779 uri: "cmn://b.dev/b3.child".to_string(),
780 bonds: vec![SporeBond {
781 uri: "cmn://a.dev/b3.parent".to_string(),
782 relation: BondRelation::SpawnedFrom,
783 id: None,
784 reason: None,
785 with: None,
786 }],
787 },
788 ];
789
790 let result = traverse_bond_graph(
791 &graph,
792 &BondTraversalQuery {
793 start: "cmn://b.dev/b3.child".to_string(),
794 direction: BondTraversalDirection::Outbound,
795 relation: Some(BondRelation::SpawnedFrom),
796 max_depth: 1,
797 },
798 );
799
800 assert_eq!(result.hits.len(), 1);
801 assert_eq!(result.hits[0].uri, "cmn://a.dev/b3.parent");
802 assert_eq!(result.hits[0].depth, 1);
803 }
804
805 #[test]
806 fn test_traverse_bond_graph_cycle() {
807 let graph = vec![
808 BondGraphNode {
809 uri: "cmn://a.dev/b3.alpha".to_string(),
810 bonds: vec![SporeBond {
811 uri: "cmn://b.dev/b3.beta".to_string(),
812 relation: BondRelation::DependsOn,
813 id: None,
814 reason: None,
815 with: None,
816 }],
817 },
818 BondGraphNode {
819 uri: "cmn://b.dev/b3.beta".to_string(),
820 bonds: vec![SporeBond {
821 uri: "cmn://a.dev/b3.alpha".to_string(),
822 relation: BondRelation::DependsOn,
823 id: None,
824 reason: None,
825 with: None,
826 }],
827 },
828 ];
829
830 let result = traverse_bond_graph(
831 &graph,
832 &BondTraversalQuery {
833 start: "cmn://a.dev/b3.alpha".to_string(),
834 direction: BondTraversalDirection::Outbound,
835 relation: Some(BondRelation::DependsOn),
836 max_depth: 10,
837 },
838 );
839
840 assert_eq!(result.hits.len(), 1);
841 assert_eq!(result.hits[0].uri, "cmn://b.dev/b3.beta");
842 }
843}