engram_core/migrate_handler/
mod.rs1mod logic;
6
7use std::path::Path;
8use std::sync::Arc;
9
10use serde::Deserialize;
11use serde_json::Value;
12
13use engram_storage::Database;
14
15use crate::config::home_directory;
16use crate::error::CoreError;
17use crate::lock_helpers;
18use crate::output::{OutputFormat, format_output};
19use crate::server::ServerState;
20
21pub use logic::{MigrationStats, perform_migration_impl};
22
23const ENGRAM_DIRECTORY: &str = ".engram";
24const DATABASE_FILENAME: &str = "engram.db";
25const CONCURRENT_SERVER_WARNING: &str = "warning: if an engram server is running for this project, migration writes may contend with server writes (SQLite WAL serializes access)";
26
27#[derive(Deserialize)]
28struct MigrateParams {
29 #[serde(default)]
30 all: bool,
31 #[serde(default)]
32 dry_run: bool,
33}
34
35pub fn execute(all: bool, dry_run: bool, format: &OutputFormat) -> Result<(), CoreError> {
36 eprintln!("{CONCURRENT_SERVER_WARNING}");
37 let source_path = resolve_legacy_db_path()?;
38 let dest_path = resolve_project_db_path()?;
39 let project_hint = if all { None } else { extract_project_hint() };
40
41 let source = Database::open_read_only(&source_path)?;
42 let dest = Database::open(&dest_path)?;
43 let stats = perform_migration_impl(&source, &dest, project_hint.as_deref(), all, dry_run)?;
44
45 let output = format_output(
46 &stats.to_json(dry_run, all, project_hint.as_deref()),
47 format,
48 );
49 println!("{output}");
50 Ok(())
51}
52
53pub async fn handle_preview(state: &Arc<ServerState>, params: Value) -> Result<Value, CoreError> {
54 let parsed: MigrateParams = serde_json::from_value(params)
55 .map_err(|error| CoreError::DispatchError(error.to_string()))?;
56 run_dispatch(state, parsed.all, true).await
57}
58
59pub async fn handle_apply(state: &Arc<ServerState>, params: Value) -> Result<Value, CoreError> {
60 let parsed: MigrateParams = serde_json::from_value(params)
61 .map_err(|error| CoreError::DispatchError(error.to_string()))?;
62 run_dispatch(state, parsed.all, parsed.dry_run).await
63}
64
65async fn run_dispatch(
66 state: &Arc<ServerState>,
67 all: bool,
68 dry_run: bool,
69) -> Result<Value, CoreError> {
70 let source_path = resolve_legacy_db_path()?;
71 let project_hint = if all { None } else { extract_project_hint() };
72 let state_clone = Arc::clone(state);
73 tokio::task::spawn_blocking(move || {
74 let source = Database::open_read_only(&source_path)?;
75 let dest = lock_helpers::lock_db(&state_clone);
76 let stats = perform_migration_impl(&source, &dest, project_hint.as_deref(), all, dry_run)?;
77 Ok::<Value, CoreError>(stats.to_json(dry_run, all, project_hint.as_deref()))
78 })
79 .await
80 .map_err(|error| CoreError::SocketError(error.to_string()))?
81}
82
83fn resolve_legacy_db_path() -> Result<String, CoreError> {
84 let home = home_directory()
85 .ok_or_else(|| CoreError::MigrationFailed("HOME environment variable not set".into()))?;
86 let legacy_path = Path::new(&home)
87 .join(ENGRAM_DIRECTORY)
88 .join(DATABASE_FILENAME);
89 if !legacy_path.exists() {
90 return Err(CoreError::MigrationSourceNotFound);
91 }
92 legacy_path
93 .to_str()
94 .map(|value| value.to_string())
95 .ok_or_else(|| CoreError::MigrationFailed("legacy path is not valid utf-8".into()))
96}
97
98fn resolve_project_db_path() -> Result<String, CoreError> {
99 let cwd = std::env::current_dir()
100 .map_err(|error| CoreError::MigrationFailed(format!("cwd unavailable: {error}")))?;
101 let engram_dir = cwd.join(ENGRAM_DIRECTORY);
102 if !engram_dir.is_dir() {
103 return Err(CoreError::InitFailed(format!(
104 "no .engram/ directory at {} — run 'engram init' first",
105 cwd.display()
106 )));
107 }
108 let dest_path = engram_dir.join(DATABASE_FILENAME);
109 dest_path
110 .to_str()
111 .map(|value| value.to_string())
112 .ok_or_else(|| CoreError::MigrationFailed("destination path is not valid utf-8".into()))
113}
114
115fn extract_project_hint() -> Option<String> {
116 let cwd = std::env::current_dir().ok()?;
117 cwd_basename(&cwd)
118}
119
120fn cwd_basename(cwd: &Path) -> Option<String> {
121 cwd.file_name()
122 .and_then(|name| name.to_str())
123 .map(|name| name.to_string())
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use std::path::PathBuf;
130
131 #[test]
132 fn cwd_basename_extracts_last_path_segment() {
133 let path = PathBuf::from("/tmp/some/project-root");
134 assert_eq!(cwd_basename(&path).as_deref(), Some("project-root"));
135 }
136}