clawshell 0.2.1

A security privileged process for the OpenClaw ecosystem.
use std::fs;
use std::path::{Path, PathBuf};

use crate::migration::core::{AmbiguityResolver, ConfigVersion};
use crate::migration::target::{MigrationTarget, TargetError, TargetMigrationOutput};
use thiserror::Error;

#[derive(Debug, Clone)]
pub struct TargetMigrationReport {
    pub target_name: String,
    pub path: PathBuf,
    pub from_version: ConfigVersion,
    pub to_version: ConfigVersion,
    pub changed: bool,
    pub backup_path: Option<PathBuf>,
    pub applied_steps: Vec<String>,
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct MigrationReport {
    pub to_version: ConfigVersion,
    pub targets: Vec<TargetMigrationReport>,
}

#[derive(Debug)]
struct PendingMigration {
    target_name: String,
    path: PathBuf,
    from_version: ConfigVersion,
    to_version: ConfigVersion,
    output: TargetMigrationOutput,
    changed: bool,
    backup_path: Option<PathBuf>,
}

#[derive(Debug, Error)]
pub enum MigrationOrchestratorError {
    #[error("failed to read '{path}': {source}")]
    ReadFile {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },

    #[error("target '{target}' failed version detection: {source}")]
    DetectVersion {
        target: String,
        #[source]
        source: TargetError,
    },

    #[error("target '{target}' migration failed: {source}")]
    MigrateTarget {
        target: String,
        #[source]
        source: TargetError,
    },

    #[error("target '{target}' validation failed: {source}")]
    ValidateTarget {
        target: String,
        #[source]
        source: TargetError,
    },

    #[error("failed to create backup '{backup}' for '{path}': {source}")]
    BackupFile {
        path: PathBuf,
        backup: PathBuf,
        #[source]
        source: std::io::Error,
    },

    #[error("failed to write '{path}': {source}{rollback_suffix}")]
    WriteFile {
        path: PathBuf,
        #[source]
        source: std::io::Error,
        rollback_suffix: String,
    },
}

#[expect(clippy::result_large_err)]
pub fn migrate_targets(
    targets: &[Box<dyn MigrationTarget>],
    resolver: &mut dyn AmbiguityResolver,
) -> Result<MigrationReport, MigrationOrchestratorError> {
    let to_version = ConfigVersion::current();
    let mut pending = Vec::new();

    for target in targets {
        let path = target.path().to_path_buf();
        let target_name = target.name().to_string();
        let original =
            fs::read_to_string(&path).map_err(|source| MigrationOrchestratorError::ReadFile {
                path: path.clone(),
                source,
            })?;
        let detected = target.detect_version(&original).map_err(|source| {
            MigrationOrchestratorError::DetectVersion {
                target: target_name.clone(),
                source,
            }
        })?;
        let from_version = detected.unwrap_or_else(ConfigVersion::legacy_baseline);
        let output = target
            .migrate(&original, &from_version, &to_version, resolver)
            .map_err(|source| MigrationOrchestratorError::MigrateTarget {
                target: target_name.clone(),
                source,
            })?;

        target.validate(&output.content).map_err(|source| {
            MigrationOrchestratorError::ValidateTarget {
                target: target_name.clone(),
                source,
            }
        })?;

        pending.push(PendingMigration {
            target_name,
            path,
            from_version,
            to_version: to_version.clone(),
            changed: output.content != original,
            output,
            backup_path: None,
        });
    }

    let mut written = Vec::<(PathBuf, PathBuf)>::new();

    for entry in &mut pending {
        if !entry.changed {
            continue;
        }

        let backup_path = next_backup_path(&entry.path);
        fs::copy(&entry.path, &backup_path).map_err(|source| {
            MigrationOrchestratorError::BackupFile {
                path: entry.path.clone(),
                backup: backup_path.clone(),
                source,
            }
        })?;

        if let Err(source) = fs::write(&entry.path, &entry.output.content) {
            let rollback_errors = rollback_written(&written);
            let rollback_suffix = if rollback_errors.is_empty() {
                String::new()
            } else {
                format!("; rollback errors: {}", rollback_errors.join(" | "))
            };
            return Err(MigrationOrchestratorError::WriteFile {
                path: entry.path.clone(),
                source,
                rollback_suffix,
            });
        }

        written.push((entry.path.clone(), backup_path.clone()));
        entry.backup_path = Some(backup_path);
    }

    let targets = pending
        .into_iter()
        .map(|entry| TargetMigrationReport {
            target_name: entry.target_name,
            path: entry.path,
            from_version: entry.from_version,
            to_version: entry.to_version,
            changed: entry.changed,
            backup_path: entry.backup_path,
            applied_steps: entry.output.applied_steps,
            warnings: entry.output.warnings,
        })
        .collect();

    Ok(MigrationReport {
        to_version,
        targets,
    })
}

fn rollback_written(written: &[(PathBuf, PathBuf)]) -> Vec<String> {
    let mut errors = Vec::new();

    for (path, backup) in written.iter().rev() {
        if let Err(e) = fs::copy(backup, path) {
            errors.push(format!(
                "failed to restore '{}' from '{}': {}",
                path.display(),
                backup.display(),
                e
            ));
        }
    }

    errors
}

pub(crate) fn next_backup_path(path: &Path) -> PathBuf {
    let first = PathBuf::from(format!("{}.bak", path.display()));
    if !first.exists() {
        return first;
    }

    let mut idx = 1;
    loop {
        let candidate = PathBuf::from(format!("{}.bak.{}", path.display(), idx));
        if !candidate.exists() {
            return candidate;
        }
        idx += 1;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_next_backup_path() {
        let unique = format!(
            "clawshell-migration-test-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        );
        let root = std::env::temp_dir().join(unique);
        std::fs::create_dir_all(&root).unwrap();

        let file = root.join("config.toml");
        std::fs::write(&file, "version = \"0.0.1\"\n").unwrap();

        let bak0 = next_backup_path(&file);
        assert_eq!(bak0, PathBuf::from(format!("{}.bak", file.display())));

        std::fs::write(&bak0, "backup 0").unwrap();
        let bak1 = next_backup_path(&file);
        assert_eq!(bak1, PathBuf::from(format!("{}.bak.1", file.display())));

        std::fs::remove_dir_all(root).unwrap();
    }
}