use super::error::{ProfileError, ProfileResult};
use super::lockfile::ProfileLockEntry;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub fn calculate_integrity(file_paths: &[String]) -> ProfileResult<String> {
let mut hasher = Sha256::new();
for file_path in file_paths {
let path = Path::new(file_path);
if path.exists() {
let content = std::fs::read(path)?;
hasher.update(&content);
hasher.update(b"\n"); }
}
let result = hasher.finalize();
Ok(format!("{result:x}"))
}
pub fn collect_all_files(profile_dir: &Path) -> ProfileResult<Vec<String>> {
let mut files = Vec::new();
for entry in WalkDir::new(profile_dir) {
let entry = entry.map_err(|e| ProfileError::IoError(std::io::Error::other(e)))?;
if entry.file_type().is_dir() {
continue;
}
let relative = entry
.path()
.strip_prefix(profile_dir)
.expect("walkdir entry should be under profile_dir");
if relative.components().any(|c| c.as_os_str() == ".git") {
continue;
}
let normalized = relative.to_string_lossy().replace('\\', "/");
if normalized == "profile.json" {
continue;
}
files.push(normalized);
}
Ok(files)
}
pub fn remove_profile_files(file_list: &[String]) -> ProfileResult<()> {
for file_path in file_list {
let path = Path::new(file_path);
if path.exists() {
std::fs::remove_file(path)?;
}
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir(parent); }
}
Ok(())
}
pub fn copy_profile_files(
source_dir: &Path,
dest_dir: &Path,
file_list: &[String],
force: bool,
conflict_owner: impl Fn(&Path) -> Option<String>,
) -> ProfileResult<Vec<String>> {
let mut copied_files = Vec::new();
for file_path in file_list {
let source_path = source_dir.join(file_path);
let dest_path = dest_dir.join(file_path);
if !source_path.exists() {
continue;
}
if dest_path.exists() && !force {
let owner = conflict_owner(&dest_path).unwrap_or_else(|| "unknown".to_string());
return Err(ProfileError::FileConflict {
path: file_path.to_string(),
owner,
});
}
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&source_path, &dest_path)?;
let absolute_path = dest_path
.canonicalize()
.unwrap_or(dest_path)
.to_string_lossy()
.replace('\\', "/");
copied_files.push(absolute_path);
}
Ok(copied_files)
}
#[derive(Debug, Clone)]
pub struct ProfileBackup {
pub entry: ProfileLockEntry,
pub files: Vec<(PathBuf, Vec<u8>)>,
}
pub fn backup_profile(workspace: &Path, entry: &ProfileLockEntry) -> ProfileResult<ProfileBackup> {
let mut files = Vec::new();
for relative in &entry.files {
let absolute = workspace.join(relative);
if absolute.exists() {
let data = fs::read(&absolute)?;
files.push((absolute, data));
}
}
Ok(ProfileBackup {
entry: entry.clone(),
files,
})
}
pub fn restore_profile(backup: &ProfileBackup) -> ProfileResult<()> {
for (path, data) in &backup.files {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, data)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_calculate_integrity_single_file() {
let temp = tempdir().unwrap();
let file1 = temp.path().join("test1.txt");
fs::write(&file1, "test content").unwrap();
let files = vec![file1.to_string_lossy().to_string()];
let integrity = calculate_integrity(&files).unwrap();
assert_eq!(integrity.len(), 64); }
#[test]
fn test_calculate_integrity_multiple_files() {
let temp = tempdir().unwrap();
let file1 = temp.path().join("test1.txt");
let file2 = temp.path().join("test2.txt");
fs::write(&file1, "content 1").unwrap();
fs::write(&file2, "content 2").unwrap();
let files = vec![
file1.to_string_lossy().to_string(),
file2.to_string_lossy().to_string(),
];
let integrity1 = calculate_integrity(&files).unwrap();
let integrity2 = calculate_integrity(&files).unwrap();
assert_eq!(integrity1, integrity2);
let files_reversed = vec![
file2.to_string_lossy().to_string(),
file1.to_string_lossy().to_string(),
];
let integrity3 = calculate_integrity(&files_reversed).unwrap();
assert_ne!(integrity1, integrity3);
}
#[test]
fn test_calculate_integrity_missing_file() {
let temp = tempdir().unwrap();
let file1 = temp.path().join("exists.txt");
let file2 = temp.path().join("missing.txt");
fs::write(&file1, "content").unwrap();
let files = vec![
file1.to_string_lossy().to_string(),
file2.to_string_lossy().to_string(),
];
let integrity = calculate_integrity(&files).unwrap();
assert_eq!(integrity.len(), 64);
}
#[test]
fn test_remove_profile_files() {
let temp = tempdir().unwrap();
let file1 = temp.path().join("test1.txt");
let file2 = temp.path().join("subdir/test2.txt");
fs::write(&file1, "content 1").unwrap();
fs::create_dir_all(temp.path().join("subdir")).unwrap();
fs::write(&file2, "content 2").unwrap();
let files = vec![
file1.to_string_lossy().to_string(),
file2.to_string_lossy().to_string(),
];
remove_profile_files(&files).unwrap();
assert!(!file1.exists());
assert!(!file2.exists());
}
#[test]
fn test_remove_profile_files_missing() {
let files = vec!["/nonexistent/file.txt".to_string()];
let result = remove_profile_files(&files);
assert!(result.is_ok());
}
#[test]
fn test_copy_profile_files_single() {
let temp = tempdir().unwrap();
let source = temp.path().join("source");
let dest = temp.path().join("dest");
fs::create_dir_all(&source).unwrap();
fs::write(source.join("test.txt"), "test content").unwrap();
let files = vec!["test.txt".to_string()];
let copied = copy_profile_files(&source, &dest, &files, false, |_| None).unwrap();
assert_eq!(copied.len(), 1);
assert!(dest.join("test.txt").exists());
let content = fs::read_to_string(dest.join("test.txt")).unwrap();
assert_eq!(content, "test content");
}
#[test]
fn test_copy_profile_files_with_subdirs() {
let temp = tempdir().unwrap();
let source = temp.path().join("source");
let dest = temp.path().join("dest");
fs::create_dir_all(source.join("subdir")).unwrap();
fs::write(source.join("subdir/test.txt"), "nested content").unwrap();
let files = vec!["subdir/test.txt".to_string()];
let copied = copy_profile_files(&source, &dest, &files, false, |_| None).unwrap();
assert_eq!(copied.len(), 1);
assert!(dest.join("subdir/test.txt").exists());
}
#[test]
fn test_copy_profile_files_skip_missing() {
let temp = tempdir().unwrap();
let source = temp.path().join("source");
let dest = temp.path().join("dest");
fs::create_dir_all(&source).unwrap();
fs::write(source.join("exists.txt"), "content").unwrap();
let files = vec!["exists.txt".to_string(), "missing.txt".to_string()];
let copied = copy_profile_files(&source, &dest, &files, false, |_| None).unwrap();
assert_eq!(copied.len(), 1);
assert!(dest.join("exists.txt").exists());
assert!(!dest.join("missing.txt").exists());
}
#[test]
fn test_copy_profile_files_conflict_detection() {
let temp = tempdir().unwrap();
let source = temp.path().join("source");
let dest = temp.path().join("dest");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(source.join("test.txt"), "source content").unwrap();
fs::write(dest.join("test.txt"), "existing content").unwrap();
let files = vec!["test.txt".to_string()];
let result = copy_profile_files(&source, &dest, &files, false, |_| {
Some("other-profile".to_string())
});
assert!(result.is_err());
match result {
Err(ProfileError::FileConflict { path, owner }) => {
assert_eq!(path, "test.txt");
assert_eq!(owner, "other-profile");
}
_ => panic!("Expected FileConflict error"),
}
let copied = copy_profile_files(&source, &dest, &files, true, |_| {
Some("other-profile".to_string())
})
.unwrap();
assert_eq!(copied.len(), 1);
let content = fs::read_to_string(dest.join("test.txt")).unwrap();
assert_eq!(content, "source content");
}
#[test]
fn test_backup_profile() {
let temp = tempdir().unwrap();
let workspace = temp.path();
fs::write(workspace.join("test1.txt"), "content 1").unwrap();
fs::create_dir_all(workspace.join("subdir")).unwrap();
fs::write(workspace.join("subdir/test2.txt"), "content 2").unwrap();
let entry = ProfileLockEntry {
name: "test-profile".to_string(),
version: "1.0.0".to_string(),
installed_at: "2025-01-11".to_string(),
files: vec!["test1.txt".to_string(), "subdir/test2.txt".to_string()],
integrity: "abc123".to_string(),
commit: None,
provider_id: None,
source: None,
};
let backup = backup_profile(workspace, &entry).unwrap();
assert_eq!(backup.entry.name, "test-profile");
assert_eq!(backup.files.len(), 2);
let (path1, data1) = &backup.files[0];
assert!(path1.ends_with("test1.txt"));
assert_eq!(data1, b"content 1");
let (path2, data2) = &backup.files[1];
assert!(path2.ends_with("test2.txt"));
assert_eq!(data2, b"content 2");
}
#[test]
fn test_backup_profile_missing_files() {
let temp = tempdir().unwrap();
let workspace = temp.path();
fs::write(workspace.join("exists.txt"), "content").unwrap();
let entry = ProfileLockEntry {
name: "test-profile".to_string(),
version: "1.0.0".to_string(),
installed_at: "2025-01-11".to_string(),
files: vec!["exists.txt".to_string(), "missing.txt".to_string()],
integrity: "abc123".to_string(),
commit: None,
provider_id: None,
source: None,
};
let backup = backup_profile(workspace, &entry).unwrap();
assert_eq!(backup.files.len(), 1);
let (path, data) = &backup.files[0];
assert!(path.ends_with("exists.txt"));
assert_eq!(data, b"content");
}
#[test]
fn test_restore_profile() {
let temp = tempdir().unwrap();
let workspace = temp.path();
fs::write(workspace.join("test1.txt"), "original 1").unwrap();
fs::create_dir_all(workspace.join("subdir")).unwrap();
fs::write(workspace.join("subdir/test2.txt"), "original 2").unwrap();
let entry = ProfileLockEntry {
name: "test-profile".to_string(),
version: "1.0.0".to_string(),
installed_at: "2025-01-11".to_string(),
files: vec!["test1.txt".to_string(), "subdir/test2.txt".to_string()],
integrity: "abc123".to_string(),
commit: None,
provider_id: None,
source: None,
};
let backup = backup_profile(workspace, &entry).unwrap();
fs::write(workspace.join("test1.txt"), "modified 1").unwrap();
fs::write(workspace.join("subdir/test2.txt"), "modified 2").unwrap();
restore_profile(&backup).unwrap();
let content1 = fs::read_to_string(workspace.join("test1.txt")).unwrap();
assert_eq!(content1, "original 1");
let content2 = fs::read_to_string(workspace.join("subdir/test2.txt")).unwrap();
assert_eq!(content2, "original 2");
}
#[test]
fn test_restore_profile_creates_directories() {
let temp = tempdir().unwrap();
let workspace = temp.path();
fs::create_dir_all(workspace.join("deep/nested")).unwrap();
fs::write(workspace.join("deep/nested/file.txt"), "content").unwrap();
let entry = ProfileLockEntry {
name: "test-profile".to_string(),
version: "1.0.0".to_string(),
installed_at: "2025-01-11".to_string(),
files: vec!["deep/nested/file.txt".to_string()],
integrity: "abc123".to_string(),
commit: None,
provider_id: None,
source: None,
};
let backup = backup_profile(workspace, &entry).unwrap();
fs::remove_dir_all(workspace.join("deep")).unwrap();
assert!(!workspace.join("deep/nested/file.txt").exists());
restore_profile(&backup).unwrap();
assert!(workspace.join("deep/nested/file.txt").exists());
let content = fs::read_to_string(workspace.join("deep/nested/file.txt")).unwrap();
assert_eq!(content, "content");
}
#[test]
fn test_backup_and_restore_roundtrip() {
let temp = tempdir().unwrap();
let workspace = temp.path();
fs::write(workspace.join("file1.txt"), "data 1").unwrap();
fs::write(workspace.join("file2.txt"), "data 2").unwrap();
fs::create_dir_all(workspace.join("dir")).unwrap();
fs::write(workspace.join("dir/file3.txt"), "data 3").unwrap();
let entry = ProfileLockEntry {
name: "profile".to_string(),
version: "1.0.0".to_string(),
installed_at: "2025-01-11".to_string(),
files: vec![
"file1.txt".to_string(),
"file2.txt".to_string(),
"dir/file3.txt".to_string(),
],
integrity: "xyz789".to_string(),
commit: None,
provider_id: None,
source: None,
};
let backup = backup_profile(workspace, &entry).unwrap();
fs::write(workspace.join("file1.txt"), "corrupted").unwrap();
fs::write(workspace.join("file2.txt"), "corrupted").unwrap();
fs::write(workspace.join("dir/file3.txt"), "corrupted").unwrap();
restore_profile(&backup).unwrap();
assert_eq!(
fs::read_to_string(workspace.join("file1.txt")).unwrap(),
"data 1"
);
assert_eq!(
fs::read_to_string(workspace.join("file2.txt")).unwrap(),
"data 2"
);
assert_eq!(
fs::read_to_string(workspace.join("dir/file3.txt")).unwrap(),
"data 3"
);
}
}