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
295impl SporeBond {
296    /// Check if this bond matches a (relation, uri) filter pair.
297    pub fn matches_filter(&self, relation: &BondRelation, uri: &str) -> bool {
298        &self.relation == relation && self.uri == uri
299    }
300}
301
302/// Lightweight bond projection containing only relation and URI.
303///
304/// Used by indexers and storage layers that don't need the full bond metadata
305/// (id, reason, with). Avoids repeated manual extraction of these two fields.
306#[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
321/// Check if all required bond filters are satisfied by the given bonds.
322///
323/// Each filter is a (relation, uri) pair. Returns true only if every filter
324/// matches at least one bond in the slice.
325pub 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
367/// Maximum allowed bond traversal depth to prevent runaway resolution.
368pub const MAX_BOND_DEPTH: u32 = 64;
369
370impl BondTraversalQuery {
371    fn default_max_depth() -> u32 {
372        1
373    }
374}
375
376/// Result of a generic BFS traversal.
377#[derive(Debug, Clone)]
378pub struct BfsResult<T> {
379    pub nodes: Vec<T>,
380    pub max_depth_reached: bool,
381}
382
383/// Generic synchronous BFS traversal.
384///
385/// Starting from `start`, calls `neighbors_fn(current_id, current_depth)` to discover
386/// neighbors. Each neighbor is represented as `(neighbor_id, node_data)`.
387/// The `node_data` of type `T` is collected into the result.
388///
389/// Handles cycle detection via `HashSet` and enforces `max_depth`.
390pub 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(&current, 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    /// Create a new spore (unsigned, uri placeholder)
497    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    /// Returns the effective author key: the embedded `core.key` if present,
548    /// otherwise falls back to the given host key.
549    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    /// Extract lightweight bond projections from this spore's bonds.
554    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    /// Verify both signatures using the same key (self-hosted case where host == author).
650    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    /// Verify content hash and size_bytes match the manifest.
681    /// Returns an error if hash or size mismatch.
682    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}