Skip to main content

rustbrain_core/
exporter.rs

1//! Portable `.brainbundle` export and import (schema v1).
2//!
3//! A brainbundle is pretty-printed JSON describing nodes and edges so a “brain”
4//! can move between repositories. With `decouple_ast = true`, Layer B (repo-local
5//! file paths and symbol nodes) is stripped — suitable for sharing concepts
6//! without binding to a specific checkout.
7
8use crate::error::{BrainError, Result};
9use crate::storage::Database;
10use crate::types::{Edge, Node, NodeType};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::Path;
14
15/// Version of the portable bundle JSON schema.
16///
17/// Increment when adding required fields or changing semantics. Readers must
18/// reject bundles with `version > BUNDLE_VERSION`.
19pub const BUNDLE_VERSION: u32 = 1;
20
21/// Portable knowledge bundle (Layer A concept core; optional AST decoupling).
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PortableBrainBundle {
24    /// Bundle format version (must be ≤ [`BUNDLE_VERSION`] to import).
25    pub version: u32,
26    /// Display name (usually the output file stem).
27    pub name: String,
28    /// Unix epoch seconds when the bundle was written.
29    pub created_at: i64,
30    /// Nodes included in the bundle.
31    pub nodes: Vec<Node>,
32    /// Edges with full metadata (`relation_type`, weights, timestamps).
33    pub edges: Vec<Edge>,
34}
35
36/// Export helper for writing `.brainbundle` files.
37pub struct BrainExporter;
38
39impl BrainExporter {
40    /// Export database nodes and edges into a portable `.brainbundle` JSON file.
41    ///
42    /// Writes via a temporary file then `rename` for crash safety.
43    ///
44    /// When `decouple_ast` is true:
45    /// - drops [`NodeType::Symbol`] nodes
46    /// - clears `file_path` / `symbol_hash` on remaining nodes
47    /// - drops edges whose endpoints were removed
48    pub fn export_bundle<P: AsRef<Path>>(
49        db: &Database,
50        output_path: P,
51        decouple_ast: bool,
52    ) -> Result<()> {
53        let mut nodes = db.get_all_nodes()?;
54        if decouple_ast {
55            nodes.retain(|n| n.node_type != NodeType::Symbol);
56            for node in &mut nodes {
57                node.file_path = None;
58                node.symbol_hash = None;
59            }
60        }
61
62        let mut edges = db.get_all_edges()?;
63        if decouple_ast {
64            let keep: std::collections::HashSet<String> =
65                nodes.iter().map(|n| n.id.clone()).collect();
66            edges.retain(|e| keep.contains(&e.source_id) && keep.contains(&e.target_id));
67        }
68
69        let bundle = PortableBrainBundle {
70            version: BUNDLE_VERSION,
71            name: output_path
72                .as_ref()
73                .file_stem()
74                .and_then(|s| s.to_str())
75                .unwrap_or("brain")
76                .to_string(),
77            created_at: chrono::Utc::now().timestamp(),
78            nodes,
79            edges,
80        };
81
82        let json = serde_json::to_string_pretty(&bundle)?;
83        // Atomic write
84        let path = output_path.as_ref();
85        let tmp = path.with_extension("brainbundle.tmp");
86        fs::write(&tmp, json)?;
87        fs::rename(&tmp, path)?;
88        Ok(())
89    }
90}
91
92/// Import helper for reading `.brainbundle` files.
93pub struct BrainImporter;
94
95impl BrainImporter {
96    /// Import a `.brainbundle` into the database (upsert nodes, then edges).
97    ///
98    /// Runs inside a single transaction. Edge FK failures abort the import
99    /// (integrity over partial success).
100    ///
101    /// # Returns
102    ///
103    /// Number of nodes upserted.
104    pub fn import_bundle<P: AsRef<Path>>(db: &Database, input_path: P) -> Result<usize> {
105        let text = fs::read_to_string(input_path.as_ref())?;
106        let bundle: PortableBrainBundle = serde_json::from_str(&text)?;
107        if bundle.version > BUNDLE_VERSION {
108            return Err(BrainError::bundle(format!(
109                "bundle version {} is newer than supported {}",
110                bundle.version, BUNDLE_VERSION
111            )));
112        }
113
114        let mut count = 0usize;
115        db.with_transaction(|conn| {
116            for node in &bundle.nodes {
117                db.insert_node_on(conn, node)?;
118                count += 1;
119            }
120            for edge in &bundle.edges {
121                // FK failures surface immediately (data integrity over silence).
122                db.insert_edge_on(conn, edge)?;
123            }
124            Ok(())
125        })?;
126        Ok(count)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::types::NodeType;
134    use tempfile::tempdir;
135
136    #[test]
137    fn export_preserves_relation_type_and_decouples_ast() {
138        let dir = tempdir().unwrap();
139        let db = Database::open_in_memory().unwrap();
140        let node = Node {
141            id: "docs/raft".into(),
142            node_type: NodeType::Concept,
143            title: "Raft".into(),
144            file_path: Some("docs/raft.md".into()),
145            symbol_hash: Some(99),
146            summary: Some("s".into()),
147            content_hash: Some("h".into()),
148            created_at: 1,
149            updated_at: 1,
150        };
151        let sym = Node {
152            id: "symbol/c/m/Foo".into(),
153            node_type: NodeType::Symbol,
154            title: "Foo".into(),
155            file_path: Some("src/lib.rs".into()),
156            symbol_hash: Some(1),
157            summary: None,
158            content_hash: None,
159            created_at: 1,
160            updated_at: 1,
161        };
162        db.insert_node(&node).unwrap();
163        db.insert_node(&sym).unwrap();
164        db.insert_edge(&Edge {
165            source_id: "docs/raft".into(),
166            target_id: "symbol/c/m/Foo".into(),
167            relation_type: "implements".into(),
168            weight: 0.5,
169            decay_rate: 0.0,
170            created_at: 7,
171        })
172        .unwrap();
173
174        let path = dir.path().join("export.brainbundle");
175        BrainExporter::export_bundle(&db, &path, true).unwrap();
176        let text = fs::read_to_string(&path).unwrap();
177        let bundle: PortableBrainBundle = serde_json::from_str(&text).unwrap();
178        assert_eq!(bundle.version, BUNDLE_VERSION);
179        assert_eq!(bundle.nodes.len(), 1);
180        assert!(bundle.nodes[0].file_path.is_none());
181        assert!(bundle.nodes[0].symbol_hash.is_none());
182        // Edge to symbol stripped because target removed
183        assert!(bundle.edges.is_empty());
184    }
185
186    #[test]
187    fn export_import_roundtrip_edges() {
188        let dir = tempdir().unwrap();
189        let db = Database::open_in_memory().unwrap();
190        for id in ["a", "b"] {
191            db.insert_node(&Node {
192                id: id.into(),
193                node_type: NodeType::Concept,
194                title: id.into(),
195                file_path: None,
196                symbol_hash: None,
197                summary: None,
198                content_hash: None,
199                created_at: 1,
200                updated_at: 1,
201            })
202            .unwrap();
203        }
204        db.insert_edge(&Edge {
205            source_id: "a".into(),
206            target_id: "b".into(),
207            relation_type: "blocks".into(),
208            weight: 1.0,
209            decay_rate: 0.2,
210            created_at: 9,
211        })
212        .unwrap();
213
214        let path = dir.path().join("b.brainbundle");
215        BrainExporter::export_bundle(&db, &path, false).unwrap();
216
217        let db2 = Database::open_in_memory().unwrap();
218        BrainImporter::import_bundle(&db2, &path).unwrap();
219        let edges = db2.get_all_edges().unwrap();
220        assert_eq!(edges.len(), 1);
221        assert_eq!(edges[0].relation_type, "blocks");
222        assert!((edges[0].decay_rate - 0.2).abs() < 1e-6);
223    }
224}