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