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        Self::export_bundle_filtered(db, output_path, decouple_ast, None)
54    }
55
56    /// Export, optionally limited to one owner scope (SubBrain share without merge).
57    ///
58    /// Hub nodes (`readme`, `changelog`, `roadmap`, `backlog`) are included when
59    /// exporting a SubBrain so the bundle still orients. Edges kept only if both
60    /// endpoints remain.
61    pub fn export_bundle_filtered<P: AsRef<Path>>(
62        db: &Database,
63        output_path: P,
64        decouple_ast: bool,
65        scope: Option<&str>,
66    ) -> Result<()> {
67        let mut nodes = db.get_all_nodes()?;
68        if let Some(sc) = scope {
69            nodes.retain(|n| {
70                n.scope == sc
71                    || crate::hubs::is_hub_node_id(&n.id)
72            });
73        }
74        if decouple_ast {
75            nodes.retain(|n| n.node_type != NodeType::Symbol);
76            for node in &mut nodes {
77                node.file_path = None;
78                node.symbol_hash = None;
79            }
80        }
81
82        let mut edges = db.get_all_edges()?;
83        {
84            let keep: std::collections::HashSet<String> =
85                nodes.iter().map(|n| n.id.clone()).collect();
86            edges.retain(|e| keep.contains(&e.source_id) && keep.contains(&e.target_id));
87        }
88
89        let bundle = PortableBrainBundle {
90            version: BUNDLE_VERSION,
91            name: output_path
92                .as_ref()
93                .file_stem()
94                .and_then(|s| s.to_str())
95                .unwrap_or("brain")
96                .to_string(),
97            created_at: chrono::Utc::now().timestamp(),
98            nodes,
99            edges,
100        };
101
102        let json = serde_json::to_string_pretty(&bundle)?;
103        // Atomic write
104        let path = output_path.as_ref();
105        let tmp = path.with_extension("brainbundle.tmp");
106        fs::write(&tmp, json)?;
107        fs::rename(&tmp, path)?;
108        Ok(())
109    }
110}
111
112/// Import helper for reading `.brainbundle` files.
113pub struct BrainImporter;
114
115impl BrainImporter {
116    /// Import a `.brainbundle` into the database (upsert nodes, then edges).
117    ///
118    /// Runs inside a single transaction. Edge FK failures abort the import
119    /// (integrity over partial success).
120    ///
121    /// # Returns
122    ///
123    /// Number of nodes upserted.
124    pub fn import_bundle<P: AsRef<Path>>(db: &Database, input_path: P) -> Result<usize> {
125        Self::import_bundle_with_scope(db, input_path, None)
126    }
127
128    /// Import a bundle, optionally stamping every node with `stamp_scope`
129    /// (keeps SubBrain isolation when merging a shared brain into multi mode).
130    pub fn import_bundle_with_scope<P: AsRef<Path>>(
131        db: &Database,
132        input_path: P,
133        stamp_scope: Option<&str>,
134    ) -> Result<usize> {
135        let text = fs::read_to_string(input_path.as_ref())?;
136        let bundle: PortableBrainBundle = serde_json::from_str(&text)?;
137        if bundle.version > BUNDLE_VERSION {
138            return Err(BrainError::bundle(format!(
139                "bundle version {} is newer than supported {}",
140                bundle.version, BUNDLE_VERSION
141            )));
142        }
143
144        let mut count = 0usize;
145        db.with_transaction(|conn| {
146            for node in &bundle.nodes {
147                let mut node = node.clone();
148                if let Some(sc) = stamp_scope {
149                    node.scope = sc.to_string();
150                }
151                db.insert_node_on(conn, &node)?;
152                count += 1;
153            }
154            for edge in &bundle.edges {
155                // FK failures surface immediately (data integrity over silence).
156                db.insert_edge_on(conn, edge)?;
157            }
158            Ok(())
159        })?;
160        Ok(count)
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::types::NodeType;
168    use tempfile::tempdir;
169
170    #[test]
171    fn export_preserves_relation_type_and_decouples_ast() {
172        let dir = tempdir().unwrap();
173        let db = Database::open_in_memory().unwrap();
174        let node = Node {
175            id: "docs/raft".into(),
176            node_type: NodeType::Concept,
177            title: "Raft".into(),
178            file_path: Some("docs/raft.md".into()),
179            symbol_hash: Some(99),
180            summary: Some("s".into()),
181            content_hash: Some("h".into()),
182            scope: crate::scopes::MAIN_SCOPE.to_string(),
183            created_at: 1,
184            updated_at: 1,
185        };
186        let sym = Node {
187            id: "symbol/c/m/Foo".into(),
188            node_type: NodeType::Symbol,
189            title: "Foo".into(),
190            file_path: Some("src/lib.rs".into()),
191            symbol_hash: Some(1),
192            summary: None,
193            content_hash: None,
194            scope: crate::scopes::MAIN_SCOPE.to_string(),
195            created_at: 1,
196            updated_at: 1,
197        };
198        db.insert_node(&node).unwrap();
199        db.insert_node(&sym).unwrap();
200        db.insert_edge(&Edge {
201            source_id: "docs/raft".into(),
202            target_id: "symbol/c/m/Foo".into(),
203            relation_type: "implements".into(),
204            weight: 0.5,
205            decay_rate: 0.0,
206            created_at: 7,
207        })
208        .unwrap();
209
210        let path = dir.path().join("export.brainbundle");
211        BrainExporter::export_bundle(&db, &path, true).unwrap();
212        let text = fs::read_to_string(&path).unwrap();
213        let bundle: PortableBrainBundle = serde_json::from_str(&text).unwrap();
214        assert_eq!(bundle.version, BUNDLE_VERSION);
215        assert_eq!(bundle.nodes.len(), 1);
216        assert!(bundle.nodes[0].file_path.is_none());
217        assert!(bundle.nodes[0].symbol_hash.is_none());
218        // Edge to symbol stripped because target removed
219        assert!(bundle.edges.is_empty());
220    }
221
222    #[test]
223    fn export_import_roundtrip_edges() {
224        let dir = tempdir().unwrap();
225        let db = Database::open_in_memory().unwrap();
226        for id in ["a", "b"] {
227            db.insert_node(&Node {
228                id: id.into(),
229                node_type: NodeType::Concept,
230                title: id.into(),
231                file_path: None,
232                symbol_hash: None,
233                summary: None,
234                content_hash: None,
235                scope: crate::scopes::MAIN_SCOPE.to_string(),
236                created_at: 1,
237                updated_at: 1,
238            })
239            .unwrap();
240        }
241        db.insert_edge(&Edge {
242            source_id: "a".into(),
243            target_id: "b".into(),
244            relation_type: "blocks".into(),
245            weight: 1.0,
246            decay_rate: 0.2,
247            created_at: 9,
248        })
249        .unwrap();
250
251        let path = dir.path().join("b.brainbundle");
252        BrainExporter::export_bundle(&db, &path, false).unwrap();
253
254        let db2 = Database::open_in_memory().unwrap();
255        BrainImporter::import_bundle(&db2, &path).unwrap();
256        let edges = db2.get_all_edges().unwrap();
257        assert_eq!(edges.len(), 1);
258        assert_eq!(edges[0].relation_type, "blocks");
259        assert!((edges[0].decay_rate - 0.2).abs() < 1e-6);
260    }
261}