1use 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
15pub const BUNDLE_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PortableBrainBundle {
24 pub version: u32,
26 pub name: String,
28 pub created_at: i64,
30 pub nodes: Vec<Node>,
32 pub edges: Vec<Edge>,
34}
35
36pub struct BrainExporter;
38
39impl BrainExporter {
40 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 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 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
112pub struct BrainImporter;
114
115impl BrainImporter {
116 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 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 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 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}