rustbrain_core/
exporter.rs1use 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 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 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
92pub struct BrainImporter;
94
95impl BrainImporter {
96 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 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 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}