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