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    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/// Tree hash configuration: algorithm, exclusions, and ignore rules
141#[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/// Local `spore.core.json` document used during development.
152#[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/// Distribution type for a spore.
161#[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/// Distribution entry in a spore manifest.
219#[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/// Bond to another spore
281#[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
329/// Maximum allowed bond traversal depth to prevent runaway resolution.
330pub 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    /// Create a new spore (unsigned, uri placeholder)
414    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    /// Verify content hash and size_bytes match the manifest.
577    /// Returns an error if hash or size mismatch.
578    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}