1use anyhow::Context;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7const STORAGE_LAYOUT_VERSION: u32 = 1;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SharedPaths {
11 pub canonical_root: PathBuf,
12 pub legacy_root: PathBuf,
13 pub engine_state_dir: PathBuf,
14 pub config_path: PathBuf,
15 pub keystore_path: PathBuf,
16 pub vault_key_path: PathBuf,
17 pub memory_db_path: PathBuf,
18 pub sidecar_release_cache_path: PathBuf,
19 pub logs_dir: PathBuf,
20 pub storage_version_path: PathBuf,
21 pub migration_report_path: PathBuf,
22}
23
24pub fn normalize_workspace_path(input: &str) -> Option<String> {
25 let trimmed = input.trim();
26 if trimmed.is_empty() {
27 return None;
28 }
29 let as_path = PathBuf::from(trimmed);
30 let absolute = if as_path.is_absolute() {
31 as_path
32 } else {
33 std::env::current_dir().ok()?.join(as_path)
34 };
35 let normalized = if absolute.exists() {
36 absolute.canonicalize().ok()?
37 } else {
38 absolute
39 };
40 Some(normalized.to_string_lossy().to_string())
41}
42
43pub fn is_within_workspace_root(path: &Path, workspace_root: &Path) -> bool {
44 let candidate = if path.exists() {
45 path.canonicalize().ok()
46 } else if path.is_absolute() {
47 Some(path.to_path_buf())
48 } else {
49 std::env::current_dir().ok().map(|cwd| cwd.join(path))
50 };
51 let Some(candidate) = candidate else {
52 return false;
53 };
54 let root = if workspace_root.exists() {
55 workspace_root
56 .canonicalize()
57 .unwrap_or_else(|_| workspace_root.to_path_buf())
58 } else {
59 workspace_root.to_path_buf()
60 };
61 let candidate = normalize_for_workspace_compare(candidate);
62 let root = normalize_for_workspace_compare(root);
63 candidate.starts_with(root)
64}
65
66fn normalize_for_workspace_compare(path: PathBuf) -> PathBuf {
67 #[cfg(windows)]
68 {
69 let mut text = path.to_string_lossy().replace('/', "\\");
73 if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
74 text = format!(r"\\{}", rest);
75 } else if let Some(rest) = text.strip_prefix(r"\\?\") {
76 text = rest.to_string();
77 }
78 PathBuf::from(text.to_ascii_lowercase())
79 }
80
81 #[cfg(not(windows))]
82 {
83 path
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct MigrationResult {
89 pub performed: bool,
90 pub reason: String,
91 pub copied: Vec<String>,
92 pub skipped: Vec<String>,
93 pub errors: Vec<String>,
94 pub timestamp_ms: u64,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98struct StorageVersionMarker {
99 version: u32,
100 timestamp_ms: u64,
101}
102
103pub fn resolve_shared_paths() -> anyhow::Result<SharedPaths> {
104 let base = dirs::data_dir().ok_or_else(|| anyhow::anyhow!("Failed to resolve data dir"))?;
105 let canonical_root = base.join("tandem");
106 let legacy_root = base.join("ai.frumu.tandem");
107
108 Ok(SharedPaths {
109 canonical_root: canonical_root.clone(),
110 legacy_root,
111 engine_state_dir: canonical_root.join("data"),
112 config_path: canonical_root.join("config.json"),
113 keystore_path: canonical_root.join("tandem.keystore"),
114 vault_key_path: canonical_root.join("vault.key"),
115 memory_db_path: canonical_root.join("memory.sqlite"),
116 sidecar_release_cache_path: canonical_root.join("sidecar_release_cache.json"),
117 logs_dir: canonical_root.join("logs"),
118 storage_version_path: canonical_root.join("storage_version.json"),
119 migration_report_path: canonical_root.join("migration_report.json"),
120 })
121}
122
123pub fn migrate_legacy_storage_if_needed(paths: &SharedPaths) -> anyhow::Result<MigrationResult> {
124 fs::create_dir_all(&paths.canonical_root)
125 .with_context(|| format!("Failed to create {:?}", paths.canonical_root))?;
126 let mut result = MigrationResult {
127 performed: false,
128 reason: String::new(),
129 copied: Vec::new(),
130 skipped: Vec::new(),
131 errors: Vec::new(),
132 timestamp_ms: now_ms(),
133 };
134
135 let canonical_empty = is_dir_effectively_empty(&paths.canonical_root)?;
136 let mut source_found = false;
137
138 let file_artifacts = [
139 "vault.key",
140 "tandem.keystore",
141 "memory.sqlite",
142 "memory.sqlite-shm",
143 "memory.sqlite-wal",
144 "config.json",
145 "sidecar_release_cache.json",
146 ];
147 let dir_artifacts = ["data", "state", "storage", "binaries", "logs"];
148
149 if paths.legacy_root.exists() {
150 source_found = true;
151 for name in file_artifacts {
152 let src = paths.legacy_root.join(name);
153 if !src.exists() {
154 continue;
155 }
156 let dst = paths.canonical_root.join(name);
157 match copy_file_guarded(&src, &dst) {
158 Ok(true) => result.copied.push(name.to_string()),
159 Ok(false) => result.skipped.push(name.to_string()),
160 Err(err) => result.errors.push(format!("{}: {}", name, err)),
161 }
162 }
163
164 for name in dir_artifacts {
165 let src = paths.legacy_root.join(name);
166 if !src.is_dir() {
167 continue;
168 }
169 let dst = paths.canonical_root.join(name);
170 match copy_dir_guarded(&src, &dst) {
171 Ok((copied, skipped)) => {
172 for entry in copied {
173 result.copied.push(format!("{}/{}", name, entry));
174 }
175 for entry in skipped {
176 result.skipped.push(format!("{}/{}", name, entry));
177 }
178 }
179 Err(err) => result.errors.push(format!("{}: {}", name, err)),
180 }
181 }
182 }
183
184 if let Some(opencode_root) = resolve_opencode_legacy_root() {
185 let src_storage = opencode_root.join("storage");
186 if src_storage.is_dir() {
187 source_found = true;
188 let dst_storage = paths.engine_state_dir.join("storage");
189 match copy_dir_guarded(&src_storage, &dst_storage) {
190 Ok((copied, skipped)) => {
191 for entry in copied {
192 result.copied.push(format!("opencode/storage/{}", entry));
193 }
194 for entry in skipped {
195 result.skipped.push(format!("opencode/storage/{}", entry));
196 }
197 }
198 Err(err) => result.errors.push(format!("opencode/storage: {}", err)),
199 }
200 }
201 }
202
203 result.performed = !result.copied.is_empty();
204 result.reason = if !source_found {
205 "legacy_not_found".to_string()
206 } else if result.performed && canonical_empty {
207 "migration_copied_into_empty_canonical".to_string()
208 } else if result.performed {
209 "migration_backfilled_missing_artifacts".to_string()
210 } else if !result.errors.is_empty() {
211 "migration_partial_error".to_string()
212 } else {
213 "migration_no_changes".to_string()
214 };
215
216 persist_storage_marker(paths)?;
217 persist_migration_report(paths, &result)?;
218 Ok(result)
219}
220
221fn persist_storage_marker(paths: &SharedPaths) -> anyhow::Result<()> {
222 let marker = StorageVersionMarker {
223 version: STORAGE_LAYOUT_VERSION,
224 timestamp_ms: now_ms(),
225 };
226 write_json(&paths.storage_version_path, &marker)
227}
228
229fn persist_migration_report(paths: &SharedPaths, report: &MigrationResult) -> anyhow::Result<()> {
230 write_json(&paths.migration_report_path, report)
231}
232
233fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
234 if let Some(parent) = path.parent() {
235 fs::create_dir_all(parent)?;
236 }
237 let text = serde_json::to_string_pretty(value)?;
238 fs::write(path, format!("{}\n", text))?;
239 Ok(())
240}
241
242fn is_dir_effectively_empty(path: &Path) -> anyhow::Result<bool> {
243 if !path.exists() {
244 return Ok(true);
245 }
246 for entry in fs::read_dir(path)? {
247 let entry = entry?;
248 let name = entry.file_name();
249 let name = name.to_string_lossy();
250 if name == "." || name == ".." {
251 continue;
252 }
253 return Ok(false);
254 }
255 Ok(true)
256}
257
258fn copy_file_guarded(src: &Path, dst: &Path) -> anyhow::Result<bool> {
259 if dst.exists() && should_skip_copy(src, dst)? {
260 return Ok(false);
261 }
262 if let Some(parent) = dst.parent() {
263 fs::create_dir_all(parent)?;
264 }
265 fs::copy(src, dst).with_context(|| format!("copy {:?} -> {:?}", src, dst))?;
266 Ok(true)
267}
268
269fn copy_dir_guarded(src: &Path, dst: &Path) -> anyhow::Result<(Vec<String>, Vec<String>)> {
270 let mut copied = Vec::new();
271 let mut skipped = Vec::new();
272 if !dst.exists() {
273 fs::create_dir_all(dst)?;
274 }
275 for entry in fs::read_dir(src)? {
276 let entry = entry?;
277 let src_path = entry.path();
278 let dst_path = dst.join(entry.file_name());
279 if entry.file_type()?.is_dir() {
280 let (child_copied, child_skipped) = copy_dir_guarded(&src_path, &dst_path)?;
281 copied.extend(child_copied);
282 skipped.extend(child_skipped);
283 } else {
284 let rel = src_path
285 .strip_prefix(src)
286 .unwrap_or(src_path.as_path())
287 .to_string_lossy()
288 .to_string();
289 if copy_file_guarded(&src_path, &dst_path)? {
290 copied.push(rel);
291 } else {
292 skipped.push(rel);
293 }
294 }
295 }
296 Ok((copied, skipped))
297}
298
299fn should_skip_copy(src: &Path, dst: &Path) -> anyhow::Result<bool> {
300 let src_meta = fs::metadata(src)?;
301 let dst_meta = fs::metadata(dst)?;
302
303 if src_meta.len() != dst_meta.len() {
304 return Ok(false);
305 }
306
307 let src_time = src_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
308 let dst_time = dst_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
309 Ok(dst_time >= src_time)
310}
311
312fn resolve_opencode_legacy_root() -> Option<PathBuf> {
313 if let Ok(override_dir) = std::env::var("TANDEM_OPENCODE_LEGACY_DIR") {
314 let path = PathBuf::from(override_dir);
315 if path.exists() {
316 return Some(path);
317 }
318 }
319 let mut candidates = Vec::new();
320 if let Some(home) = dirs::home_dir() {
321 candidates.push(home.join(".local").join("share").join("opencode"));
322 }
323 if let Some(local) = dirs::data_local_dir() {
324 candidates.push(local.join("opencode"));
325 }
326 if let Some(data) = dirs::data_dir() {
327 candidates.push(data.join("opencode"));
328 }
329 candidates.into_iter().find(|path| path.exists())
330}
331
332fn now_ms() -> u64 {
333 match SystemTime::now().duration_since(UNIX_EPOCH) {
334 Ok(dur) => dur.as_millis() as u64,
335 Err(_) => 0,
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[cfg(windows)]
344 #[test]
345 fn workspace_root_compare_handles_verbatim_prefix_mismatch() {
346 let workspace = PathBuf::from(r"\\?\C:\Users\evang\work\tandem-engine\tandem");
347 let candidate = PathBuf::from(r"C:\Users\evang\work\tandem-engine\tandem\*");
348 assert!(is_within_workspace_root(&candidate, &workspace));
349
350 let workspace_plain = PathBuf::from(r"C:\Users\evang\work\tandem-engine\tandem");
351 let candidate_verbatim = PathBuf::from(r"\\?\C:\Users\evang\work\tandem-engine\tandem\src");
352 assert!(is_within_workspace_root(
353 &candidate_verbatim,
354 &workspace_plain
355 ));
356 }
357
358 #[test]
359 fn migration_copies_from_legacy_when_canonical_empty() {
360 let temp = tempfile::tempdir().expect("tempdir");
361 let legacy = temp.path().join("legacy");
362 let canonical = temp.path().join("canonical");
363 fs::create_dir_all(&legacy).expect("legacy");
364 fs::write(legacy.join("vault.key"), "abc").expect("write");
365 fs::write(legacy.join("memory.sqlite"), "db").expect("write");
366
367 let paths = SharedPaths {
368 canonical_root: canonical.clone(),
369 legacy_root: legacy.clone(),
370 engine_state_dir: canonical.join("data"),
371 config_path: canonical.join("config.json"),
372 keystore_path: canonical.join("tandem.keystore"),
373 vault_key_path: canonical.join("vault.key"),
374 memory_db_path: canonical.join("memory.sqlite"),
375 sidecar_release_cache_path: canonical.join("sidecar_release_cache.json"),
376 logs_dir: canonical.join("logs"),
377 storage_version_path: canonical.join("storage_version.json"),
378 migration_report_path: canonical.join("migration_report.json"),
379 };
380
381 let report = migrate_legacy_storage_if_needed(&paths).expect("migrate");
382 assert!(
383 report.reason == "migration_copied_into_empty_canonical"
384 || report.reason == "migration_partial_error"
385 );
386 assert!(paths.vault_key_path.exists());
387 assert!(paths.memory_db_path.exists());
388 assert!(paths.storage_version_path.exists());
389 }
390
391 #[test]
392 fn migration_backfills_keys_when_canonical_already_has_files() {
393 let temp = tempfile::tempdir().expect("tempdir");
394 let legacy = temp.path().join("legacy");
395 let canonical = temp.path().join("canonical");
396 fs::create_dir_all(&legacy).expect("legacy");
397 fs::create_dir_all(canonical.join("logs")).expect("logs");
398 fs::write(legacy.join("vault.key"), "abc").expect("write");
399 fs::write(legacy.join("tandem.keystore"), "secret").expect("write");
400
401 let paths = SharedPaths {
402 canonical_root: canonical.clone(),
403 legacy_root: legacy.clone(),
404 engine_state_dir: canonical.join("data"),
405 config_path: canonical.join("config.json"),
406 keystore_path: canonical.join("tandem.keystore"),
407 vault_key_path: canonical.join("vault.key"),
408 memory_db_path: canonical.join("memory.sqlite"),
409 sidecar_release_cache_path: canonical.join("sidecar_release_cache.json"),
410 logs_dir: canonical.join("logs"),
411 storage_version_path: canonical.join("storage_version.json"),
412 migration_report_path: canonical.join("migration_report.json"),
413 };
414
415 let report = migrate_legacy_storage_if_needed(&paths).expect("migrate");
416 assert_eq!(report.reason, "migration_backfilled_missing_artifacts");
417 assert!(paths.vault_key_path.exists());
418 assert!(paths.keystore_path.exists());
419 }
420
421 #[test]
422 fn migration_copies_opencode_storage_into_engine_state_storage() {
423 let temp = tempfile::tempdir().expect("tempdir");
424 let opencode_root = temp.path().join("opencode");
425 let src_storage = opencode_root.join("storage").join("session").join("global");
426 fs::create_dir_all(&src_storage).expect("opencode storage");
427 fs::write(src_storage.join("ses_abc.json"), r#"{"id":"ses_abc"}"#).expect("write");
428
429 let legacy = temp.path().join("legacy-missing");
430 let canonical = temp.path().join("canonical");
431 fs::create_dir_all(&canonical).expect("canonical");
432
433 std::env::set_var(
434 "TANDEM_OPENCODE_LEGACY_DIR",
435 opencode_root.to_string_lossy().to_string(),
436 );
437 let paths = SharedPaths {
438 canonical_root: canonical.clone(),
439 legacy_root: legacy,
440 engine_state_dir: canonical.join("data"),
441 config_path: canonical.join("config.json"),
442 keystore_path: canonical.join("tandem.keystore"),
443 vault_key_path: canonical.join("vault.key"),
444 memory_db_path: canonical.join("memory.sqlite"),
445 sidecar_release_cache_path: canonical.join("sidecar_release_cache.json"),
446 logs_dir: canonical.join("logs"),
447 storage_version_path: canonical.join("storage_version.json"),
448 migration_report_path: canonical.join("migration_report.json"),
449 };
450
451 let report = migrate_legacy_storage_if_needed(&paths).expect("migrate");
452 assert!(report.performed);
453 assert!(paths
454 .engine_state_dir
455 .join("storage")
456 .join("session")
457 .join("global")
458 .join("ses_abc.json")
459 .exists());
460 std::env::remove_var("TANDEM_OPENCODE_LEGACY_DIR");
461 }
462}