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::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}