oxicode/foundation/
migrate.rs1use std::path::{Path, PathBuf};
39
40use serde::{Deserialize, Serialize};
41
42pub fn default_checkpoint_path() -> PathBuf {
44 crate::foundation::fetch_oxicode_home()
45 .unwrap_or_else(|| PathBuf::from("."))
46 .join("migration")
47 .join("brain.json")
48}
49
50pub fn default_legacy_path() -> PathBuf {
52 crate::foundation::fetch_oxicode_home()
53 .unwrap_or_else(|| PathBuf::from("."))
54 .join("memory")
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct Checkpoint {
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub last_id: Option<String>,
63 #[serde(default)]
65 pub migrated: usize,
66 #[serde(default)]
68 pub skipped: usize,
69 #[serde(default)]
71 pub failed: usize,
72}
73
74impl Checkpoint {
75 pub fn load(path: &Path) -> Self {
78 match std::fs::read(path) {
79 Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
80 Err(_) => Self::default(),
81 }
82 }
83
84 pub fn save(&self, path: &Path) -> std::io::Result<()> {
87 if let Some(parent) = path.parent() {
88 std::fs::create_dir_all(parent)?;
89 }
90 let tmp = path.with_extension("json.tmp");
91 let bytes = serde_json::to_vec_pretty(self).expect("checkpoint is JSON-serializable");
92 std::fs::write(&tmp, bytes)?;
93 std::fs::rename(&tmp, path)?;
94 Ok(())
95 }
96
97 pub fn last_id(&self) -> Option<&str> {
99 self.last_id.as_deref()
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum MigrationOutcome {
106 Inserted(String),
108 Skipped(String),
110}
111
112#[derive(Debug, Clone)]
114pub struct LegacyItem {
115 pub content: String,
116 pub kind: String,
117 pub subject: String,
118}
119
120pub struct Migration<'a> {
123 backend: &'a crate::foundation::brain::BrainMemoryBackend,
124 checkpoint_path: &'a Path,
125 state: Checkpoint,
126}
127
128impl<'a> Migration<'a> {
129 pub fn new(
130 backend: &'a crate::foundation::brain::BrainMemoryBackend,
131 checkpoint_path: &'a Path,
132 ) -> Self {
133 let state = Checkpoint::load(checkpoint_path);
134 Self {
135 backend,
136 checkpoint_path,
137 state,
138 }
139 }
140
141 pub fn migrate_one(
147 &mut self,
148 item: LegacyItem,
149 ) -> Result<MigrationOutcome, crate::foundation::brain::MigrationError> {
150 let phase = self.backend.health();
151
152 if matches!(phase, crate::foundation::brain::BrainHealth::Unavailable) {
153 return Err(crate::foundation::brain::MigrationError::BackendOffline);
154 }
155
156 let id = self
157 .backend
158 .put_sync(&item.content, &item.kind, &item.subject)
159 .map_err(crate::foundation::brain::MigrationError::Backend)?;
160
161 self.state.last_id = Some(id.clone());
162 self.state.migrated += 1;
163 self.state
164 .save(self.checkpoint_path)
165 .map_err(|e| crate::foundation::brain::MigrationError::Checkpoint(e.to_string()))?;
166
167 Ok(MigrationOutcome::Inserted(id))
168 }
169
170 pub fn state(&self) -> &Checkpoint {
172 &self.state
173 }
174}
175
176pub struct LegacyMemoryReader {
180 path: PathBuf,
181}
182
183impl LegacyMemoryReader {
184 pub fn new(path: PathBuf) -> Self {
185 Self { path }
186 }
187
188 pub fn for_default_home() -> Self {
190 Self::new(default_legacy_path())
191 }
192
193 pub fn batches(&self, size: usize) -> LegacyBatches {
201 LegacyBatches {
202 path: self.path.join("items.jsonl"),
203 batch_size: size.max(1),
204 pending: Vec::new(),
205 exhausted: false,
206 loaded: false,
207 }
208 }
209}
210
211pub struct LegacyBatches {
214 path: PathBuf,
215 batch_size: usize,
216 pending: Vec<LegacyItem>,
217 exhausted: bool,
218 loaded: bool,
219}
220
221impl Iterator for LegacyBatches {
222 type Item = Vec<LegacyItem>;
223
224 fn next(&mut self) -> Option<Self::Item> {
225 if self.pending.is_empty() && self.exhausted {
226 return None;
227 }
228 if !self.pending.is_empty() {
229 return Some(std::mem::take(&mut self.pending));
230 }
231
232 if self.loaded {
234 self.exhausted = true;
235 return None;
236 }
237 self.loaded = true;
238
239 let contents = match std::fs::read_to_string(&self.path) {
240 Ok(c) => c,
241 Err(_) => {
242 self.exhausted = true;
243 return None;
244 }
245 };
246 let mut acc = Vec::with_capacity(self.batch_size);
247 for line in contents.lines() {
248 let line = line.trim();
249 if line.is_empty() {
250 continue;
251 }
252 if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
253 let content = value
254 .get("content")
255 .and_then(|v| v.as_str())
256 .unwrap_or("")
257 .to_string();
258 let kind = value
259 .get("kind")
260 .and_then(|v| v.as_str())
261 .unwrap_or("fact")
262 .to_string();
263 let subject = value
264 .get("subject")
265 .and_then(|v| v.as_str())
266 .unwrap_or("")
267 .to_string();
268 acc.push(LegacyItem {
269 content,
270 kind,
271 subject,
272 });
273 }
274 }
275
276 if acc.is_empty() {
277 self.exhausted = true;
278 return None;
279 }
280 let take = acc.len().min(self.batch_size);
281 let first_chunk: Vec<_> = acc.drain(..take).collect();
282 self.pending = acc;
283 if self.pending.is_empty() {
284 self.exhausted = true;
285 }
286 Some(first_chunk)
287 }
288}
289
290pub fn archive_legacy_default() -> std::io::Result<PathBuf> {
293 let legacy = default_legacy_path();
294 let home = legacy
295 .parent()
296 .map(|p| p.to_path_buf())
297 .unwrap_or_else(|| PathBuf::from("."));
298 let ts = std::time::SystemTime::now()
299 .duration_since(std::time::UNIX_EPOCH)
300 .map(|d| d.as_secs())
301 .unwrap_or(0);
302 let dest = home
303 .join("archive")
304 .join("memory")
305 .join(format!("archive-{ts}"));
306 std::fs::create_dir_all(dest.parent().unwrap())?;
307 if legacy.exists() {
308 std::fs::rename(&legacy, &dest)?;
309 }
310 Ok(dest)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn checkpoint_round_trip() {
319 let tmp = tempfile::tempdir().unwrap();
320 let path = tmp.path().join("brain.json");
321
322 let mut cp = Checkpoint::default();
323 cp.last_id = Some("m-42".to_string());
324 cp.migrated = 42;
325 cp.save(&path).unwrap();
326
327 let loaded = Checkpoint::load(&path);
328 assert_eq!(loaded.last_id.as_deref(), Some("m-42"));
329 assert_eq!(loaded.migrated, 42);
330 }
331
332 #[test]
333 fn checkpoint_missing_file_is_default() {
334 let cp = Checkpoint::load(Path::new("/does/not/exist/brain.json"));
335 assert_eq!(cp.last_id, None);
336 assert_eq!(cp.migrated, 0);
337 }
338
339 #[test]
340 fn legacy_reader_returns_empty_when_store_missing() {
341 let reader = LegacyMemoryReader::new(PathBuf::from("/no/such/path"));
342 let batches: Vec<_> = reader.batches(2).collect();
343 assert!(batches.is_empty());
344 }
345
346 #[test]
347 fn legacy_reader_batches_items_jsonl() {
348 let tmp = tempfile::tempdir().unwrap();
349 let items = tmp.path().join("items.jsonl");
350 std::fs::write(
351 &items,
352 "{\"content\":\"a\",\"kind\":\"fact\",\"subject\":\"s\"}\n\
353 {\"content\":\"b\",\"kind\":\"fact\",\"subject\":\"s\"}\n\
354 {\"content\":\"c\",\"kind\":\"fact\",\"subject\":\"s\"}\n",
355 )
356 .unwrap();
357 let reader = LegacyMemoryReader::new(tmp.path().to_path_buf());
358 let batches: Vec<_> = reader.batches(2).collect();
359 let total: usize = batches.iter().map(|b| b.len()).sum();
360 assert_eq!(total, 3);
361 assert_eq!(batches.len(), 2);
362 assert_eq!(batches[0].len(), 2);
363 assert_eq!(batches[1].len(), 1);
364 }
365
366 #[test]
367 fn legacy_reader_skips_malformed_lines() {
368 let tmp = tempfile::tempdir().unwrap();
369 let items = tmp.path().join("items.jsonl");
370 std::fs::write(
371 &items,
372 "{\"content\":\"a\"}\n\
373 this is not json\n\
374 {\"content\":\"b\"}\n",
375 )
376 .unwrap();
377 let reader = LegacyMemoryReader::new(tmp.path().to_path_buf());
378 let batches: Vec<_> = reader.batches(10).collect();
379 let total: usize = batches.iter().map(|b| b.len()).sum();
380 assert_eq!(total, 2);
381 }
382
383 #[test]
384 fn archive_legacy_default_moves_when_present() {
385 let tmp = tempfile::tempdir().unwrap();
386 let legacy = tmp.path().join("memory");
387 std::fs::create_dir_all(&legacy).unwrap();
388 std::fs::write(legacy.join("items.jsonl"), "{\"content\":\"x\"}\n").unwrap();
389
390 let home = tmp.path().to_string_lossy().to_string();
391 unsafe {
394 std::env::set_var("OXICODE_HOME", &home);
395 }
396 let dest = archive_legacy_default().unwrap();
397 unsafe {
398 std::env::remove_var("OXICODE_HOME");
399 }
400
401 assert!(
402 dest.exists(),
403 "archive path should exist: {}",
404 dest.display()
405 );
406 assert!(!legacy.exists(), "legacy path should be moved");
407 let archived = std::fs::read_to_string(dest.join("items.jsonl")).unwrap();
408 assert_eq!(archived.trim(), "{\"content\":\"x\"}");
409 }
410}