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
295impl SporeBond {
296 pub fn matches_filter(&self, relation: &BondRelation, uri: &str) -> bool {
298 &self.relation == relation && self.uri == uri
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct BondProjection {
308 pub uri: String,
309 pub relation: BondRelation,
310}
311
312impl From<&SporeBond> for BondProjection {
313 fn from(bond: &SporeBond) -> Self {
314 Self {
315 uri: bond.uri.clone(),
316 relation: bond.relation.clone(),
317 }
318 }
319}
320
321pub fn bonds_match_all(bonds: &[SporeBond], filters: &[(BondRelation, String)]) -> bool {
326 filters
327 .iter()
328 .all(|(rel, uri)| bonds.iter().any(|b| b.matches_filter(rel, uri)))
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum BondTraversalDirection {
334 Outbound,
335 Inbound,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339pub struct BondGraphNode {
340 pub uri: String,
341 #[serde(default)]
342 pub bonds: Vec<SporeBond>,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346pub struct BondTraversalQuery {
347 pub start: String,
348 pub direction: BondTraversalDirection,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub relation: Option<BondRelation>,
351 #[serde(default = "BondTraversalQuery::default_max_depth")]
352 pub max_depth: u32,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct BondTraversalHit {
357 pub uri: String,
358 pub depth: u32,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct BondTraversalResult {
363 pub hits: Vec<BondTraversalHit>,
364 pub max_depth_reached: bool,
365}
366
367pub const MAX_BOND_DEPTH: u32 = 64;
369
370impl BondTraversalQuery {
371 fn default_max_depth() -> u32 {
372 1
373 }
374}
375
376#[derive(Debug, Clone)]
378pub struct BfsResult<T> {
379 pub nodes: Vec<T>,
380 pub max_depth_reached: bool,
381}
382
383pub fn bfs_traverse<T, F>(start: &str, max_depth: u32, mut neighbors_fn: F) -> BfsResult<T>
391where
392 F: FnMut(&str, u32) -> Vec<(String, T)>,
393{
394 let mut visited = HashSet::new();
395 let mut queue = VecDeque::new();
396 let mut results = Vec::new();
397 let mut depth_reached = false;
398
399 visited.insert(start.to_string());
400 queue.push_back((start.to_string(), 0u32));
401
402 while let Some((current, depth)) = queue.pop_front() {
403 if depth >= max_depth {
404 depth_reached = true;
405 continue;
406 }
407 for (neighbor_id, node_data) in neighbors_fn(¤t, depth) {
408 if visited.insert(neighbor_id.clone()) {
409 results.push(node_data);
410 queue.push_back((neighbor_id, depth + 1));
411 }
412 }
413 }
414
415 BfsResult {
416 nodes: results,
417 max_depth_reached: depth_reached,
418 }
419}
420
421pub fn traverse_bond_graph(
422 graph: &[BondGraphNode],
423 query: &BondTraversalQuery,
424) -> BondTraversalResult {
425 let mut graph_by_uri = HashMap::with_capacity(graph.len());
426 for node in graph {
427 graph_by_uri.insert(node.uri.as_str(), node);
428 }
429
430 let mut hits = Vec::new();
431 let mut visited = HashSet::new();
432 let mut queue = VecDeque::new();
433 let mut max_depth_reached = false;
434
435 visited.insert(query.start.clone());
436 queue.push_back((query.start.as_str(), 0_u32));
437
438 while let Some((current_uri, depth)) = queue.pop_front() {
439 let next_edges: Vec<(&str, &BondRelation)> = match query.direction {
440 BondTraversalDirection::Outbound => graph_by_uri
441 .get(current_uri)
442 .into_iter()
443 .flat_map(|node| node.bonds.iter())
444 .filter(|bond| {
445 query
446 .relation
447 .as_ref()
448 .map(|relation| bond.relation == *relation)
449 .unwrap_or(true)
450 })
451 .map(|bond| (bond.uri.as_str(), &bond.relation))
452 .collect(),
453 BondTraversalDirection::Inbound => graph
454 .iter()
455 .flat_map(|node| {
456 node.bonds
457 .iter()
458 .filter(move |bond| bond.uri == current_uri)
459 .map(move |bond| (node.uri.as_str(), &bond.relation))
460 })
461 .filter(|(_, relation)| {
462 query
463 .relation
464 .as_ref()
465 .map(|expected| *relation == expected)
466 .unwrap_or(true)
467 })
468 .collect(),
469 };
470
471 for (next_uri, _) in next_edges {
472 let next_depth = depth.saturating_add(1);
473 if next_depth > query.max_depth {
474 max_depth_reached = true;
475 continue;
476 }
477 if !visited.insert(next_uri.to_string()) {
478 continue;
479 }
480
481 hits.push(BondTraversalHit {
482 uri: next_uri.to_string(),
483 depth: next_depth,
484 });
485 queue.push_back((next_uri, next_depth));
486 }
487 }
488
489 BondTraversalResult {
490 hits,
491 max_depth_reached,
492 }
493}
494
495impl Spore {
496 pub fn new(
498 domain: &str,
499 name: &str,
500 synopsis: &str,
501 intent: Vec<String>,
502 license: &str,
503 ) -> Self {
504 Self {
505 schema: SPORE_SCHEMA.to_string(),
506 capsule: SporeCapsule {
507 uri: String::new(),
508 core: SporeCore {
509 id: String::new(),
510 version: String::new(),
511 name: name.to_string(),
512 domain: domain.to_string(),
513 key: String::new(),
514 synopsis: synopsis.to_string(),
515 intent,
516 license: license.to_string(),
517 mutations: vec![],
518 size_bytes: 0,
519 bonds: vec![],
520 tree: SporeTree::default(),
521 updated_at_epoch_ms: 0,
522 },
523 core_signature: String::new(),
524 dist: vec![],
525 },
526 capsule_signature: String::new(),
527 }
528 }
529
530 pub fn uri(&self) -> &str {
531 &self.capsule.uri
532 }
533
534 pub fn author_domain(&self) -> &str {
535 &self.capsule.core.domain
536 }
537
538 pub fn timestamp_ms(&self) -> u64 {
539 self.capsule.core.updated_at_epoch_ms
540 }
541
542 pub fn embedded_core_key(&self) -> Option<&str> {
543 let key = self.capsule.core.key.as_str();
544 (!key.is_empty()).then_some(key)
545 }
546
547 pub fn effective_author_key<'a>(&'a self, host_key: &'a str) -> &'a str {
550 self.embedded_core_key().unwrap_or(host_key)
551 }
552
553 pub fn extract_bonds(&self) -> Vec<BondProjection> {
555 self.capsule
556 .core
557 .bonds
558 .iter()
559 .map(BondProjection::from)
560 .collect()
561 }
562
563 pub fn tree(&self) -> &SporeTree {
564 &self.capsule.core.tree
565 }
566
567 pub fn distributions(&self) -> &[SporeDist] {
568 &self.capsule.dist
569 }
570
571 pub fn followed_strain_uris(&self) -> Vec<&str> {
572 self.capsule
573 .core
574 .bonds
575 .iter()
576 .filter(|bond| bond.relation == BondRelation::Follows)
577 .map(|bond| bond.uri.as_str())
578 .collect()
579 }
580
581 pub fn follows_uri(&self, uri: &str) -> bool {
582 self.capsule
583 .core
584 .bonds
585 .iter()
586 .any(|bond| bond.relation == BondRelation::Follows && bond.uri == uri)
587 }
588
589 pub fn follows_all(&self, required_uris: &[&str]) -> bool {
590 required_uris.iter().all(|uri| self.follows_uri(uri))
591 }
592
593 pub fn extended_strain_uris(&self) -> Vec<&str> {
594 self.capsule
595 .core
596 .bonds
597 .iter()
598 .filter(|bond| bond.relation == BondRelation::Extends)
599 .map(|bond| bond.uri.as_str())
600 .collect()
601 }
602
603 pub fn extends_uri(&self, uri: &str) -> bool {
604 self.capsule
605 .core
606 .bonds
607 .iter()
608 .any(|bond| bond.relation == BondRelation::Extends && bond.uri == uri)
609 }
610
611 pub fn extends_all(&self, required_uris: &[&str]) -> bool {
612 required_uris.iter().all(|uri| self.extends_uri(uri))
613 }
614
615 pub fn is_strain_definition(&self, accepted_root_lineage_uris: &[&str]) -> bool {
616 accepted_root_lineage_uris
617 .iter()
618 .any(|uri| self.extends_uri(uri))
619 }
620
621 pub fn spawned_from_uri(&self) -> Option<&str> {
622 self.capsule
623 .core
624 .bonds
625 .iter()
626 .find(|bond| bond.relation.is_spawned_from())
627 .map(|bond| bond.uri.as_str())
628 }
629
630 pub fn spawned_from_hash(&self) -> Option<String> {
631 crate::uri::parse_uri(self.spawned_from_uri()?)
632 .ok()
633 .and_then(|uri| uri.hash)
634 }
635
636 pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
637 crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
638 }
639
640 pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
641 crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
642 }
643
644 pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
645 self.verify_core_signature(author_key)?;
646 self.verify_capsule_signature(host_key)
647 }
648
649 pub fn verify_self_hosted_signatures(&self, key: &str) -> Result<()> {
651 self.verify_signatures(key, key)
652 }
653
654 pub fn computed_uri_hash_from_tree_hash(&self, tree_hash: &str) -> Result<String> {
655 crate::crypto::hash::compute_tree_signed_core_hash(
656 tree_hash,
657 &self.capsule.core,
658 &self.capsule.core_signature,
659 )
660 }
661
662 pub fn verify_uri_hash_from_tree_hash(
663 &self,
664 expected_hash: &str,
665 tree_hash: &str,
666 ) -> Result<()> {
667 let actual_hash = self.computed_uri_hash_from_tree_hash(tree_hash)?;
668 super::verify_expected_uri_hash(&actual_hash, expected_hash)
669 }
670
671 pub fn verify_content_hash(
672 &self,
673 entries: &[crate::tree::TreeEntry],
674 expected_hash: &str,
675 ) -> Result<()> {
676 let tree_hash = self.tree().compute_hash(entries)?;
677 self.verify_uri_hash_from_tree_hash(expected_hash, &tree_hash)
678 }
679
680 pub fn verify_content_hash_and_size(
683 &self,
684 entries: &[crate::tree::TreeEntry],
685 expected_hash: &str,
686 ) -> Result<()> {
687 let (tree_hash, computed_size) = self.tree().compute_hash_and_size(entries)?;
688 self.verify_uri_hash_from_tree_hash(expected_hash, &tree_hash)?;
689 let declared = self.capsule.core.size_bytes;
690 if declared > 0 && computed_size != declared {
691 return Err(anyhow!(
692 "size_bytes mismatch: declared {} but computed {}",
693 declared,
694 computed_size
695 ));
696 }
697 Ok(())
698 }
699}
700
701impl SporeCoreDocument {
702 pub fn into_core(self) -> SporeCore {
703 self.core
704 }
705
706 pub fn core(&self) -> &SporeCore {
707 &self.core
708 }
709}
710
711#[cfg(test)]
712#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
713mod tests {
714
715 use super::*;
716 #[test]
717 fn test_spore_bond() {
718 let reference = SporeBond {
719 uri: "cmn://other.com/b3.def456".to_string(),
720 relation: BondRelation::DependsOn,
721 id: None,
722 reason: None,
723 with: None,
724 };
725
726 let json = serde_json::to_string(&reference).unwrap_or_default();
727 assert!(json.contains("\"uri\""));
728 assert!(json.contains("\"relation\""));
729 assert!(json.contains("depends_on"));
730 }
731
732 #[test]
733 fn test_spore_new() {
734 let spore = Spore::new(
735 "example.com",
736 "my-tool",
737 "A useful tool",
738 vec!["Initial release".to_string()],
739 "MIT",
740 );
741 assert_eq!(spore.schema, SPORE_SCHEMA);
742 assert_eq!(spore.capsule.core.name, "my-tool");
743 assert_eq!(spore.capsule.core.domain, "example.com");
744 assert_eq!(spore.capsule.core.synopsis, "A useful tool");
745 assert_eq!(spore.capsule.core.license, "MIT");
746 }
747
748 #[test]
749 fn test_spore_with_bonds() {
750 let mut spore = Spore::new(
751 "example.com",
752 "child-spore",
753 "A child spore",
754 vec!["v1.0".to_string()],
755 "MIT",
756 );
757 spore.capsule.core.bonds = vec![
758 SporeBond {
759 uri: "cmn://parent.com/b3.parent1".to_string(),
760 relation: BondRelation::SpawnedFrom,
761 id: None,
762 reason: None,
763 with: None,
764 },
765 SporeBond {
766 uri: "cmn://lib.com/b3.lib1".to_string(),
767 relation: BondRelation::DependsOn,
768 id: None,
769 reason: None,
770 with: None,
771 },
772 ];
773 spore.capsule.core_signature = "ed25519.abc".to_string();
774 spore.capsule_signature = "ed25519.def".to_string();
775 spore.capsule.uri = "cmn://example.com/b3.abc123".to_string();
776
777 let json = serde_json::to_string_pretty(&spore).unwrap_or_default();
778 assert!(json.contains("\"bonds\""));
779 assert!(json.contains("spawned_from"));
780 assert!(json.contains("parent.com"));
781
782 let parsed: Spore = serde_json::from_str(&json).unwrap();
783 assert_eq!(parsed.capsule.core.bonds.len(), 2);
784 assert_eq!(
785 parsed.capsule.core.bonds[0].relation,
786 BondRelation::SpawnedFrom
787 );
788 assert_eq!(
789 parsed.capsule.core.bonds[1].relation,
790 BondRelation::DependsOn
791 );
792 }
793
794 #[test]
795 fn test_spore_tree() {
796 let tree = SporeTree {
797 algorithm: "blob_tree_blake3_nfc".to_string(),
798 exclude_names: vec!["node_modules".to_string(), ".git".to_string()],
799 follow_rules: vec![".gitignore".to_string()],
800 };
801
802 let json = serde_json::to_string(&tree).unwrap_or_default();
803 assert!(json.contains("blob_tree_blake3_nfc"));
804 assert!(json.contains("node_modules"));
805 assert!(json.contains(".gitignore"));
806
807 let parsed: SporeTree = serde_json::from_str(&json).unwrap();
808 assert_eq!(parsed.algorithm, "blob_tree_blake3_nfc");
809 assert_eq!(parsed.exclude_names.len(), 2);
810 assert_eq!(parsed.follow_rules.len(), 1);
811 }
812
813 #[test]
814 fn test_spore_strain_helpers() {
815 let mut spore = Spore::new(
816 "example.com",
817 "strain-child",
818 "A strain child",
819 vec!["Initial release".to_string()],
820 "MIT",
821 );
822 spore.capsule.core.bonds = vec![
823 SporeBond {
824 uri: "cmn://service.dev/b3.service".to_string(),
825 relation: BondRelation::Follows,
826 id: None,
827 reason: None,
828 with: None,
829 },
830 SporeBond {
831 uri: "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2".to_string(),
832 relation: BondRelation::Extends,
833 id: None,
834 reason: None,
835 with: None,
836 },
837 SporeBond {
838 uri: "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W".to_string(),
839 relation: BondRelation::Extends,
840 id: None,
841 reason: None,
842 with: None,
843 },
844 ];
845
846 assert_eq!(
847 spore.followed_strain_uris(),
848 vec!["cmn://service.dev/b3.service"]
849 );
850 assert!(spore.follows_uri("cmn://service.dev/b3.service"));
851 assert!(spore.follows_all(&["cmn://service.dev/b3.service"]));
852 assert_eq!(
853 spore.extended_strain_uris(),
854 vec![
855 "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2",
856 "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W"
857 ]
858 );
859 assert!(
860 spore.extends_uri("cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W")
861 );
862 assert!(spore.extends_all(&[
863 "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2",
864 "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W"
865 ]));
866 assert!(spore
867 .is_strain_definition(&["cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2"]));
868 assert!(!spore.is_strain_definition(&[
869 "cmn://other-root.dev/b3.Bp7WKXh4Rxx2jyu5taj2aeMorH5YLT4R8DF5rWq7jZjq"
870 ]));
871 }
872
873 #[test]
874 fn test_traverse_bond_graph_outbound() {
875 let graph = vec![
876 BondGraphNode {
877 uri: "cmn://a.dev/b3.parent".to_string(),
878 bonds: vec![],
879 },
880 BondGraphNode {
881 uri: "cmn://b.dev/b3.child".to_string(),
882 bonds: vec![SporeBond {
883 uri: "cmn://a.dev/b3.parent".to_string(),
884 relation: BondRelation::SpawnedFrom,
885 id: None,
886 reason: None,
887 with: None,
888 }],
889 },
890 ];
891
892 let result = traverse_bond_graph(
893 &graph,
894 &BondTraversalQuery {
895 start: "cmn://b.dev/b3.child".to_string(),
896 direction: BondTraversalDirection::Outbound,
897 relation: Some(BondRelation::SpawnedFrom),
898 max_depth: 1,
899 },
900 );
901
902 assert_eq!(result.hits.len(), 1);
903 assert_eq!(result.hits[0].uri, "cmn://a.dev/b3.parent");
904 assert_eq!(result.hits[0].depth, 1);
905 }
906
907 #[test]
908 fn test_traverse_bond_graph_cycle() {
909 let graph = vec![
910 BondGraphNode {
911 uri: "cmn://a.dev/b3.alpha".to_string(),
912 bonds: vec![SporeBond {
913 uri: "cmn://b.dev/b3.beta".to_string(),
914 relation: BondRelation::DependsOn,
915 id: None,
916 reason: None,
917 with: None,
918 }],
919 },
920 BondGraphNode {
921 uri: "cmn://b.dev/b3.beta".to_string(),
922 bonds: vec![SporeBond {
923 uri: "cmn://a.dev/b3.alpha".to_string(),
924 relation: BondRelation::DependsOn,
925 id: None,
926 reason: None,
927 with: None,
928 }],
929 },
930 ];
931
932 let result = traverse_bond_graph(
933 &graph,
934 &BondTraversalQuery {
935 start: "cmn://a.dev/b3.alpha".to_string(),
936 direction: BondTraversalDirection::Outbound,
937 relation: Some(BondRelation::DependsOn),
938 max_depth: 10,
939 },
940 );
941
942 assert_eq!(result.hits.len(), 1);
943 assert_eq!(result.hits[0].uri, "cmn://b.dev/b3.beta");
944 }
945}