Skip to main content

engram_core/migrate_handler/
mod.rs

1//! Migration orchestration: resolves legacy/project DB paths, opens databases, dispatches work.
2//!
3//! Pure migration logic (filtering, duplicate-skip, bulk insert) lives in [`logic`].
4
5mod 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}