Skip to main content

tandem_core/
storage_paths.rs

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        // Canonicalized Windows paths often use the verbatim prefix (\\?\),
70        // while runtime paths may not. Strip that prefix so equivalent paths
71        // compare consistently for workspace sandbox checks.
72        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}