#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
use std::path::{Path, PathBuf};
use worktree_setup_config::LoadedConfig;
use worktree_setup_copy::count_files_with_progress;
use crate::ApplyConfigOptions;
use crate::error::OperationError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationType {
Symlink,
Copy,
Overwrite,
CopyGlob,
Template,
Unstaged,
}
impl std::fmt::Display for OperationType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Symlink => write!(f, "symlink"),
Self::Copy => write!(f, "copy"),
Self::Overwrite => write!(f, "overwrite"),
Self::CopyGlob => write!(f, "copy"),
Self::Template => write!(f, "template"),
Self::Unstaged => write!(f, "unstaged"),
}
}
}
#[derive(Debug, Clone)]
pub struct PlannedOperation {
pub display_path: String,
pub operation_type: OperationType,
pub source: PathBuf,
pub target: PathBuf,
pub file_count: u64,
pub is_directory: bool,
pub will_skip: bool,
pub skip_reason: Option<String>,
}
pub fn plan_operations(
config: &LoadedConfig,
main_worktree: &Path,
target_worktree: &Path,
options: &ApplyConfigOptions,
) -> Result<Vec<PlannedOperation>, OperationError> {
plan_operations_with_progress(
config,
main_worktree,
target_worktree,
options,
&|_, _, _, _| {},
)
}
pub fn plan_operations_with_progress<F>(
config: &LoadedConfig,
main_worktree: &Path,
target_worktree: &Path,
_options: &ApplyConfigOptions,
on_progress: &F,
) -> Result<Vec<PlannedOperation>, OperationError>
where
F: Fn(usize, usize, &str, Option<u64>),
{
let mut operations = Vec::new();
let config_relative_dir = config
.config_dir
.strip_prefix(main_worktree)
.unwrap_or(&config.config_dir);
let total_ops = config.config.symlinks.len()
+ config.config.copy.len()
+ config.config.overwrite.len()
+ config.config.copy_glob.len()
+ config.config.templates.len();
let mut current_op = 0usize;
for symlink_path in &config.config.symlinks {
current_op += 1;
let source = main_worktree.join(config_relative_dir).join(symlink_path);
let target = target_worktree.join(config_relative_dir).join(symlink_path);
let display_path = config_relative_dir.join(symlink_path);
let display_str = display_path.to_string_lossy().to_string();
on_progress(current_op, total_ops, &display_str, None);
let (will_skip, skip_reason) = if !source.exists() {
(true, Some("not found".to_string()))
} else if target.exists() || target.is_symlink() {
(true, Some("exists".to_string()))
} else {
(false, None)
};
operations.push(PlannedOperation {
display_path: display_str,
operation_type: OperationType::Symlink,
source,
target,
file_count: 0, is_directory: false,
will_skip,
skip_reason,
});
}
for copy_path in &config.config.copy {
current_op += 1;
let source = main_worktree.join(config_relative_dir).join(copy_path);
let target = target_worktree.join(config_relative_dir).join(copy_path);
let display_path = config_relative_dir.join(copy_path);
let display_str = display_path.to_string_lossy().to_string();
on_progress(current_op, total_ops, &display_str, None);
let (will_skip, skip_reason, file_count, is_directory) = if !source.exists() {
(true, Some("not found".to_string()), 0, false)
} else if target.exists() {
(true, Some("exists".to_string()), 0, false)
} else {
let is_dir = source.is_dir();
let count = if is_dir {
count_files_with_progress(&source, |n| {
on_progress(current_op, total_ops, &display_str, Some(n));
})
} else {
1
};
(false, None, count, is_dir)
};
operations.push(PlannedOperation {
display_path: display_str,
operation_type: OperationType::Copy,
source,
target,
file_count,
is_directory,
will_skip,
skip_reason,
});
}
for overwrite_path in &config.config.overwrite {
current_op += 1;
let source = main_worktree.join(config_relative_dir).join(overwrite_path);
let target = target_worktree
.join(config_relative_dir)
.join(overwrite_path);
let display_path = config_relative_dir.join(overwrite_path);
let display_str = display_path.to_string_lossy().to_string();
on_progress(current_op, total_ops, &display_str, None);
let (will_skip, skip_reason, file_count, is_directory) = if !source.exists() {
(true, Some("not found".to_string()), 0, false)
} else {
let is_dir = source.is_dir();
let count = if is_dir {
count_files_with_progress(&source, |n| {
on_progress(current_op, total_ops, &display_str, Some(n));
})
} else {
1
};
(false, None, count, is_dir)
};
operations.push(PlannedOperation {
display_path: display_str,
operation_type: OperationType::Overwrite,
source,
target,
file_count,
is_directory,
will_skip,
skip_reason,
});
}
for pattern in &config.config.copy_glob {
current_op += 1;
let search_dir = main_worktree.join(config_relative_dir);
let full_pattern = search_dir.join(pattern).to_string_lossy().to_string();
on_progress(current_op, total_ops, pattern, None);
for entry in glob::glob(&full_pattern)? {
if let Ok(source) = entry {
if let Ok(rel_path) = source.strip_prefix(&search_dir) {
let target = target_worktree.join(config_relative_dir).join(rel_path);
let display_path = config_relative_dir.join(rel_path);
let (will_skip, skip_reason) = if target.exists() {
(true, Some("exists".to_string()))
} else {
(false, None)
};
operations.push(PlannedOperation {
display_path: display_path.to_string_lossy().to_string(),
operation_type: OperationType::CopyGlob,
source,
target,
file_count: 1,
is_directory: false,
will_skip,
skip_reason,
});
}
}
}
}
for template in &config.config.templates {
current_op += 1;
let source = main_worktree
.join(config_relative_dir)
.join(&template.source);
let target = target_worktree
.join(config_relative_dir)
.join(&template.target);
let display_path = format!(
"{} -> {}",
config_relative_dir.join(&template.source).display(),
config_relative_dir.join(&template.target).display()
);
on_progress(current_op, total_ops, &display_path, None);
let (will_skip, skip_reason) = if !source.exists() {
(true, Some("not found".to_string()))
} else if target.exists() {
(true, Some("exists".to_string()))
} else {
(false, None)
};
operations.push(PlannedOperation {
display_path,
operation_type: OperationType::Template,
source,
target,
file_count: 1,
is_directory: false,
will_skip,
skip_reason,
});
}
Ok(operations)
}
pub fn plan_unstaged_operations(
unstaged_files: &[String],
main_worktree: &Path,
target_worktree: &Path,
) -> Vec<PlannedOperation> {
let mut operations = Vec::new();
for file in unstaged_files {
let source = main_worktree.join(file);
let target = target_worktree.join(file);
if source.exists() {
operations.push(PlannedOperation {
display_path: file.clone(),
operation_type: OperationType::Unstaged,
source,
target,
file_count: 1,
is_directory: false,
will_skip: false,
skip_reason: None,
});
}
}
operations
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use worktree_setup_config::Config;
fn create_test_config(dir: &Path) -> LoadedConfig {
LoadedConfig {
config: Config {
description: "Test".to_string(),
symlinks: vec!["data".to_string()],
copy: vec!["config.json".to_string()],
overwrite: vec!["settings.json".to_string()],
..Default::default()
},
config_path: dir.join("worktree.config.toml"),
config_dir: dir.to_path_buf(),
relative_path: "worktree.config.toml".to_string(),
}
}
#[test]
fn test_plan_operations_basic() {
let main_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::create_dir_all(main_dir.path().join("data")).unwrap();
fs::write(main_dir.path().join("config.json"), "{}").unwrap();
fs::write(main_dir.path().join("settings.json"), "{}").unwrap();
let config = create_test_config(main_dir.path());
let options = ApplyConfigOptions::default();
let ops = plan_operations(&config, main_dir.path(), target_dir.path(), &options).unwrap();
assert_eq!(ops.len(), 3);
assert_eq!(ops[0].operation_type, OperationType::Symlink);
assert_eq!(ops[1].operation_type, OperationType::Copy);
assert_eq!(ops[2].operation_type, OperationType::Overwrite);
}
#[test]
fn test_plan_operations_skip_existing() {
let main_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::write(main_dir.path().join("config.json"), "{}").unwrap();
fs::write(target_dir.path().join("config.json"), "existing").unwrap();
let config = LoadedConfig {
config: Config {
copy: vec!["config.json".to_string()],
..Default::default()
},
config_path: main_dir.path().join("worktree.config.toml"),
config_dir: main_dir.path().to_path_buf(),
relative_path: "worktree.config.toml".to_string(),
};
let options = ApplyConfigOptions::default();
let ops = plan_operations(&config, main_dir.path(), target_dir.path(), &options).unwrap();
assert_eq!(ops.len(), 1);
assert!(ops[0].will_skip);
assert_eq!(ops[0].skip_reason, Some("exists".to_string()));
}
#[test]
fn test_plan_operations_directory_file_count() {
let main_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
let data_dir = main_dir.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
fs::write(data_dir.join("file1.txt"), "1").unwrap();
fs::write(data_dir.join("file2.txt"), "2").unwrap();
fs::create_dir(data_dir.join("subdir")).unwrap();
fs::write(data_dir.join("subdir/file3.txt"), "3").unwrap();
let config = LoadedConfig {
config: Config {
copy: vec!["data".to_string()],
..Default::default()
},
config_path: main_dir.path().join("worktree.config.toml"),
config_dir: main_dir.path().to_path_buf(),
relative_path: "worktree.config.toml".to_string(),
};
let options = ApplyConfigOptions::default();
let ops = plan_operations(&config, main_dir.path(), target_dir.path(), &options).unwrap();
assert_eq!(ops.len(), 1);
assert!(ops[0].is_directory);
assert_eq!(ops[0].file_count, 3);
}
#[test]
fn test_plan_operations_with_progress_callback() {
use std::cell::RefCell;
let main_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::create_dir_all(main_dir.path().join("data")).unwrap();
fs::write(main_dir.path().join("config.json"), "{}").unwrap();
let config = LoadedConfig {
config: Config {
symlinks: vec!["data".to_string()],
copy: vec!["config.json".to_string()],
..Default::default()
},
config_path: main_dir.path().join("worktree.config.toml"),
config_dir: main_dir.path().to_path_buf(),
relative_path: "worktree.config.toml".to_string(),
};
let options = ApplyConfigOptions::default();
let progress_calls = RefCell::new(Vec::new());
let ops = plan_operations_with_progress(
&config,
main_dir.path(),
target_dir.path(),
&options,
&|current, total, path, _file_count| {
progress_calls
.borrow_mut()
.push((current, total, path.to_string()));
},
)
.unwrap();
let calls = progress_calls.into_inner();
assert_eq!(ops.len(), 2);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0], (1, 2, "data".to_string()));
assert_eq!(calls[1], (2, 2, "config.json".to_string()));
}
#[test]
fn test_plan_unstaged_operations() {
let main_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::write(main_dir.path().join("modified.txt"), "content").unwrap();
fs::write(main_dir.path().join("untracked.txt"), "content").unwrap();
let unstaged = vec!["modified.txt".to_string(), "untracked.txt".to_string()];
let ops = plan_unstaged_operations(&unstaged, main_dir.path(), target_dir.path());
assert_eq!(ops.len(), 2);
assert_eq!(ops[0].operation_type, OperationType::Unstaged);
assert_eq!(ops[1].operation_type, OperationType::Unstaged);
}
}