Skip to main content

substrate/model/
spore.rs

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/// Full Spore manifest (content-addressed)
100#[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/// Spore capsule containing uri, core, core_signature, and distribution info
109#[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/// Core spore data (immutable, part of hash)
118#[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/// Tree hash configuration: algorithm, exclusions, and ignore rules
143#[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/// Local `spore.core.json` document used during development.
154#[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/// Distribution type for a spore.
163#[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/// Distribution entry in a spore manifest.
221#[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/// Bond to another spore
283#[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
331/// Maximum allowed bond traversal depth to prevent runaway resolution.
332pub 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    /// Create a new spore (unsigned, uri placeholder)
416    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    /// Verify content hash and size_bytes match the manifest.
579    /// Returns an error if hash or size mismatch.
580    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}