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 effective author key for this spore.
650    ///
651    /// Returns the author key that was used: the embedded `core.key` when
652    /// present, otherwise the provided host key.
653    pub fn verify_signatures_with_effective_author<'a>(
654        &'a self,
655        host_key: &'a str,
656    ) -> Result<&'a str> {
657        let author_key = self.effective_author_key(host_key);
658        self.verify_signatures(host_key, author_key)?;
659        Ok(author_key)
660    }
661
662    /// Verify both signatures using the same key (self-hosted case where host == author).
663    pub fn verify_self_hosted_signatures(&self, key: &str) -> Result<()> {
664        self.verify_signatures(key, key)
665    }
666
667    pub fn computed_uri_hash_from_tree_hash(&self, tree_hash: &str) -> Result<String> {
668        crate::crypto::hash::compute_tree_signed_core_hash(
669            tree_hash,
670            &self.capsule.core,
671            &self.capsule.core_signature,
672        )
673    }
674
675    pub fn verify_uri_hash_from_tree_hash(
676        &self,
677        expected_hash: &str,
678        tree_hash: &str,
679    ) -> Result<()> {
680        let actual_hash = self.computed_uri_hash_from_tree_hash(tree_hash)?;
681        super::verify_expected_uri_hash(&actual_hash, expected_hash)
682    }
683
684    pub fn verify_content_hash(
685        &self,
686        entries: &[crate::tree::TreeEntry],
687        expected_hash: &str,
688    ) -> Result<()> {
689        let tree_hash = self.tree().compute_hash(entries)?;
690        self.verify_uri_hash_from_tree_hash(expected_hash, &tree_hash)
691    }
692
693    /// Verify content hash and size_bytes match the manifest.
694    /// Returns an error if hash or size mismatch.
695    pub fn verify_content_hash_and_size(
696        &self,
697        entries: &[crate::tree::TreeEntry],
698        expected_hash: &str,
699    ) -> Result<()> {
700        let (tree_hash, computed_size) = self.tree().compute_hash_and_size(entries)?;
701        self.verify_uri_hash_from_tree_hash(expected_hash, &tree_hash)?;
702        let declared = self.capsule.core.size_bytes;
703        if declared > 0 && computed_size != declared {
704            return Err(anyhow!(
705                "size_bytes mismatch: declared {} but computed {}",
706                declared,
707                computed_size
708            ));
709        }
710        Ok(())
711    }
712}
713
714impl SporeCoreDocument {
715    pub fn into_core(self) -> SporeCore {
716        self.core
717    }
718
719    pub fn core(&self) -> &SporeCore {
720        &self.core
721    }
722}
723
724#[cfg(test)]
725#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
726mod tests {
727
728    use super::*;
729    #[test]
730    fn test_spore_bond() {
731        let reference = SporeBond {
732            uri: "cmn://other.com/b3.def456".to_string(),
733            relation: BondRelation::DependsOn,
734            id: None,
735            reason: None,
736            with: None,
737        };
738
739        let json = serde_json::to_string(&reference).unwrap_or_default();
740        assert!(json.contains("\"uri\""));
741        assert!(json.contains("\"relation\""));
742        assert!(json.contains("depends_on"));
743    }
744
745    #[test]
746    fn test_spore_new() {
747        let spore = Spore::new(
748            "example.com",
749            "my-tool",
750            "A useful tool",
751            vec!["Initial release".to_string()],
752            "MIT",
753        );
754        assert_eq!(spore.schema, SPORE_SCHEMA);
755        assert_eq!(spore.capsule.core.name, "my-tool");
756        assert_eq!(spore.capsule.core.domain, "example.com");
757        assert_eq!(spore.capsule.core.synopsis, "A useful tool");
758        assert_eq!(spore.capsule.core.license, "MIT");
759    }
760
761    #[test]
762    fn test_spore_with_bonds() {
763        let mut spore = Spore::new(
764            "example.com",
765            "child-spore",
766            "A child spore",
767            vec!["v1.0".to_string()],
768            "MIT",
769        );
770        spore.capsule.core.bonds = vec![
771            SporeBond {
772                uri: "cmn://parent.com/b3.parent1".to_string(),
773                relation: BondRelation::SpawnedFrom,
774                id: None,
775                reason: None,
776                with: None,
777            },
778            SporeBond {
779                uri: "cmn://lib.com/b3.lib1".to_string(),
780                relation: BondRelation::DependsOn,
781                id: None,
782                reason: None,
783                with: None,
784            },
785        ];
786        spore.capsule.core_signature = "ed25519.abc".to_string();
787        spore.capsule_signature = "ed25519.def".to_string();
788        spore.capsule.uri = "cmn://example.com/b3.abc123".to_string();
789
790        let json = serde_json::to_string_pretty(&spore).unwrap_or_default();
791        assert!(json.contains("\"bonds\""));
792        assert!(json.contains("spawned_from"));
793        assert!(json.contains("parent.com"));
794
795        let parsed: Spore = serde_json::from_str(&json).unwrap();
796        assert_eq!(parsed.capsule.core.bonds.len(), 2);
797        assert_eq!(
798            parsed.capsule.core.bonds[0].relation,
799            BondRelation::SpawnedFrom
800        );
801        assert_eq!(
802            parsed.capsule.core.bonds[1].relation,
803            BondRelation::DependsOn
804        );
805    }
806
807    #[test]
808    fn test_spore_tree() {
809        let tree = SporeTree {
810            algorithm: "blob_tree_blake3_nfc".to_string(),
811            exclude_names: vec!["node_modules".to_string(), ".git".to_string()],
812            follow_rules: vec![".gitignore".to_string()],
813        };
814
815        let json = serde_json::to_string(&tree).unwrap_or_default();
816        assert!(json.contains("blob_tree_blake3_nfc"));
817        assert!(json.contains("node_modules"));
818        assert!(json.contains(".gitignore"));
819
820        let parsed: SporeTree = serde_json::from_str(&json).unwrap();
821        assert_eq!(parsed.algorithm, "blob_tree_blake3_nfc");
822        assert_eq!(parsed.exclude_names.len(), 2);
823        assert_eq!(parsed.follow_rules.len(), 1);
824    }
825
826    #[test]
827    fn test_spore_strain_helpers() {
828        let mut spore = Spore::new(
829            "example.com",
830            "strain-child",
831            "A strain child",
832            vec!["Initial release".to_string()],
833            "MIT",
834        );
835        spore.capsule.core.bonds = vec![
836            SporeBond {
837                uri: "cmn://service.dev/b3.service".to_string(),
838                relation: BondRelation::Follows,
839                id: None,
840                reason: None,
841                with: None,
842            },
843            SporeBond {
844                uri: "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2".to_string(),
845                relation: BondRelation::Extends,
846                id: None,
847                reason: None,
848                with: None,
849            },
850            SporeBond {
851                uri: "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W".to_string(),
852                relation: BondRelation::Extends,
853                id: None,
854                reason: None,
855                with: None,
856            },
857        ];
858
859        assert_eq!(
860            spore.followed_strain_uris(),
861            vec!["cmn://service.dev/b3.service"]
862        );
863        assert!(spore.follows_uri("cmn://service.dev/b3.service"));
864        assert!(spore.follows_all(&["cmn://service.dev/b3.service"]));
865        assert_eq!(
866            spore.extended_strain_uris(),
867            vec![
868                "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2",
869                "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W"
870            ]
871        );
872        assert!(
873            spore.extends_uri("cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W")
874        );
875        assert!(spore.extends_all(&[
876            "cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2",
877            "cmn://parent.dev/b3.BMjugPDk6SFJiCLvTTWJtbD6LxSmhw6KBbXQh7Lixv5W"
878        ]));
879        assert!(spore
880            .is_strain_definition(&["cmn://root.dev/b3.3yMR7vZQ9hL2xKJdFtN8wPcB6sY1mXgU4eH5pTa2"]));
881        assert!(!spore.is_strain_definition(&[
882            "cmn://other-root.dev/b3.Bp7WKXh4Rxx2jyu5taj2aeMorH5YLT4R8DF5rWq7jZjq"
883        ]));
884    }
885
886    #[test]
887    fn test_traverse_bond_graph_outbound() {
888        let graph = vec![
889            BondGraphNode {
890                uri: "cmn://a.dev/b3.parent".to_string(),
891                bonds: vec![],
892            },
893            BondGraphNode {
894                uri: "cmn://b.dev/b3.child".to_string(),
895                bonds: vec![SporeBond {
896                    uri: "cmn://a.dev/b3.parent".to_string(),
897                    relation: BondRelation::SpawnedFrom,
898                    id: None,
899                    reason: None,
900                    with: None,
901                }],
902            },
903        ];
904
905        let result = traverse_bond_graph(
906            &graph,
907            &BondTraversalQuery {
908                start: "cmn://b.dev/b3.child".to_string(),
909                direction: BondTraversalDirection::Outbound,
910                relation: Some(BondRelation::SpawnedFrom),
911                max_depth: 1,
912            },
913        );
914
915        assert_eq!(result.hits.len(), 1);
916        assert_eq!(result.hits[0].uri, "cmn://a.dev/b3.parent");
917        assert_eq!(result.hits[0].depth, 1);
918    }
919
920    #[test]
921    fn test_traverse_bond_graph_cycle() {
922        let graph = vec![
923            BondGraphNode {
924                uri: "cmn://a.dev/b3.alpha".to_string(),
925                bonds: vec![SporeBond {
926                    uri: "cmn://b.dev/b3.beta".to_string(),
927                    relation: BondRelation::DependsOn,
928                    id: None,
929                    reason: None,
930                    with: None,
931                }],
932            },
933            BondGraphNode {
934                uri: "cmn://b.dev/b3.beta".to_string(),
935                bonds: vec![SporeBond {
936                    uri: "cmn://a.dev/b3.alpha".to_string(),
937                    relation: BondRelation::DependsOn,
938                    id: None,
939                    reason: None,
940                    with: None,
941                }],
942            },
943        ];
944
945        let result = traverse_bond_graph(
946            &graph,
947            &BondTraversalQuery {
948                start: "cmn://a.dev/b3.alpha".to_string(),
949                direction: BondTraversalDirection::Outbound,
950                relation: Some(BondRelation::DependsOn),
951                max_depth: 10,
952            },
953        );
954
955        assert_eq!(result.hits.len(), 1);
956        assert_eq!(result.hits[0].uri, "cmn://b.dev/b3.beta");
957    }
958}