Skip to main content

kaizen/core/
legacy_import.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Copy legacy workspace data into Kaizen home without changing the workspace.
3
4use anyhow::Result;
5use std::path::Path;
6
7const MARKER: &str = "LEGACY_IMPORTED.txt";
8
9pub enum ImportOutcome {
10    Skipped,
11    AlreadyImported,
12    Conflict,
13    Imported,
14}
15
16/// Copy `<workspace>/.kaizen` into `target`; leave source bytes untouched.
17pub fn import_legacy(workspace: &Path, target: &Path) -> Result<ImportOutcome> {
18    let source = workspace.join(".kaizen");
19    if let Some(outcome) = skip_reason(&source, target)? {
20        return Ok(outcome);
21    }
22    validate_tree(&source)?;
23    std::fs::create_dir_all(target)?;
24    copy_entries(&source, target)?;
25    write_marker(&source, target)?;
26    Ok(ImportOutcome::Imported)
27}
28
29fn skip_reason(source: &Path, target: &Path) -> Result<Option<ImportOutcome>> {
30    if source_missing(source)? {
31        return Ok(Some(ImportOutcome::Skipped));
32    }
33    if target.join(MARKER).exists() {
34        return Ok(Some(ImportOutcome::AlreadyImported));
35    }
36    Ok(target_has_data(target)?.then_some(ImportOutcome::Conflict))
37}
38
39fn source_missing(source: &Path) -> Result<bool> {
40    match std::fs::symlink_metadata(source) {
41        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true),
42        Err(error) => Err(error.into()),
43        Ok(_) => {
44            anyhow::ensure!(
45                validated_metadata(source)?.is_dir(),
46                "legacy root must be a directory"
47            );
48            Ok(false)
49        }
50    }
51}
52
53fn target_has_data(target: &Path) -> Result<bool> {
54    Ok(target.exists() && std::fs::read_dir(target)?.next().transpose()?.is_some())
55}
56
57fn copy_entries(source: &Path, target: &Path) -> Result<()> {
58    for entry in std::fs::read_dir(source)? {
59        let entry = entry?;
60        if entry.file_name() != "MIGRATED.txt" {
61            copy_recursive(&entry.path(), &target.join(entry.file_name()))?;
62        }
63    }
64    Ok(())
65}
66
67fn validate_tree(root: &Path) -> Result<()> {
68    anyhow::ensure!(
69        validated_metadata(root)?.is_dir(),
70        "legacy root must be a directory"
71    );
72    for entry in std::fs::read_dir(root)? {
73        validate_entry(&entry?.path())?;
74    }
75    Ok(())
76}
77
78fn validate_entry(path: &Path) -> Result<()> {
79    let metadata = validated_metadata(path)?;
80    if metadata.is_dir() {
81        validate_tree(path)?;
82    }
83    Ok(())
84}
85
86fn validated_metadata(path: &Path) -> Result<std::fs::Metadata> {
87    let metadata = std::fs::symlink_metadata(path)?;
88    anyhow::ensure!(
89        !metadata.file_type().is_symlink(),
90        "legacy import rejects symlink: {}",
91        path.display()
92    );
93    anyhow::ensure!(
94        metadata.is_file() || metadata.is_dir(),
95        "unsupported legacy entry: {}",
96        path.display()
97    );
98    Ok(metadata)
99}
100
101fn copy_recursive(source: &Path, target: &Path) -> Result<()> {
102    let metadata = validated_metadata(source)?;
103    if !metadata.is_dir() {
104        std::fs::copy(source, target)?;
105        return Ok(());
106    }
107    std::fs::create_dir_all(target)?;
108    copy_entries(source, target)
109}
110
111fn write_marker(source: &Path, target: &Path) -> Result<()> {
112    let message = format!(
113        "copied from: {}\nsource left unchanged; remove it manually after verification\n",
114        source.display()
115    );
116    std::fs::write(target.join(MARKER), message)?;
117    Ok(())
118}