#![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 | Self::CopyGlob => write!(f, "copy"),
Self::Overwrite => write!(f, "overwrite"),
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 force_overwrite: bool,
}
fn resolve_path(base: &Path, config_relative_dir: &Path, path: &str) -> (PathBuf, String) {
path.strip_prefix('/').map_or_else(
|| {
let display = config_relative_dir.join(path);
(base.join(&display), display.to_string_lossy().to_string())
},
|stripped| {
(base.join(stripped), stripped.to_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,
&|_, _, _, _| {},
)
}
struct PlanContext<'a, F> {
config_relative_dir: &'a Path,
main_worktree: &'a Path,
target_worktree: &'a Path,
overwrite: bool,
on_progress: &'a F,
total_ops: usize,
}
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 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 ctx = PlanContext {
config_relative_dir,
main_worktree,
target_worktree,
overwrite: options.overwrite_existing,
on_progress,
total_ops,
};
let mut current_op = 0usize;
let mut operations = Vec::new();
operations.extend(plan_symlink_ops(
&ctx,
&mut current_op,
&config.config.symlinks,
));
operations.extend(plan_copy_ops(&ctx, &mut current_op, &config.config.copy));
operations.extend(plan_overwrite_ops(
&ctx,
&mut current_op,
&config.config.overwrite,
));
operations.extend(plan_glob_ops(
&ctx,
&mut current_op,
&config.config.copy_glob,
)?);
operations.extend(plan_template_ops(
&ctx,
&mut current_op,
&config.config.templates,
));
Ok(operations)
}
fn plan_symlink_ops<F>(
ctx: &PlanContext<'_, F>,
current_op: &mut usize,
symlinks: &[String],
) -> Vec<PlannedOperation>
where
F: Fn(usize, usize, &str, Option<u64>),
{
let mut operations = Vec::new();
for symlink_path in symlinks {
*current_op += 1;
let (source, display_str) =
resolve_path(ctx.main_worktree, ctx.config_relative_dir, symlink_path);
let (target, _) = resolve_path(ctx.target_worktree, ctx.config_relative_dir, symlink_path);
(ctx.on_progress)(*current_op, ctx.total_ops, &display_str, None);
let (will_skip, skip_reason, force) = if !source.exists() {
(true, Some("not found".to_string()), false)
} else if target.exists() || target.is_symlink() {
if ctx.overwrite {
(false, None, true)
} else {
(true, Some("exists".to_string()), false)
}
} else {
(false, None, false)
};
operations.push(PlannedOperation {
display_path: display_str,
operation_type: OperationType::Symlink,
source,
target,
file_count: 0,
is_directory: false,
will_skip,
skip_reason,
force_overwrite: force,
});
}
operations
}
fn plan_copy_ops<F>(
ctx: &PlanContext<'_, F>,
current_op: &mut usize,
copies: &[String],
) -> Vec<PlannedOperation>
where
F: Fn(usize, usize, &str, Option<u64>),
{
let mut operations = Vec::new();
for copy_path in copies {
*current_op += 1;
let (source, display_str) =
resolve_path(ctx.main_worktree, ctx.config_relative_dir, copy_path);
let (target, _) = resolve_path(ctx.target_worktree, ctx.config_relative_dir, copy_path);
(ctx.on_progress)(*current_op, ctx.total_ops, &display_str, None);
let (will_skip, skip_reason, file_count, is_directory, op_type) = if !source.exists() {
(
true,
Some("not found".to_string()),
0,
false,
OperationType::Copy,
)
} else if target.exists() {
if ctx.overwrite {
let is_dir = source.is_dir();
let count = if is_dir {
count_files_with_progress(&source, |n| {
(ctx.on_progress)(*current_op, ctx.total_ops, &display_str, Some(n));
})
} else {
1
};
(false, None, count, is_dir, OperationType::Overwrite)
} else {
(
true,
Some("exists".to_string()),
0,
false,
OperationType::Copy,
)
}
} else {
let is_dir = source.is_dir();
let count = if is_dir {
count_files_with_progress(&source, |n| {
(ctx.on_progress)(*current_op, ctx.total_ops, &display_str, Some(n));
})
} else {
1
};
(false, None, count, is_dir, OperationType::Copy)
};
operations.push(PlannedOperation {
display_path: display_str,
operation_type: op_type,
source,
target,
file_count,
is_directory,
will_skip,
skip_reason,
force_overwrite: false,
});
}
operations
}
fn plan_overwrite_ops<F>(
ctx: &PlanContext<'_, F>,
current_op: &mut usize,
overwrites: &[String],
) -> Vec<PlannedOperation>
where
F: Fn(usize, usize, &str, Option<u64>),
{
let mut operations = Vec::new();
for overwrite_path in overwrites {
*current_op += 1;
let (source, display_str) =
resolve_path(ctx.main_worktree, ctx.config_relative_dir, overwrite_path);
let (target, _) =
resolve_path(ctx.target_worktree, ctx.config_relative_dir, overwrite_path);
(ctx.on_progress)(*current_op, ctx.total_ops, &display_str, None);
let (will_skip, skip_reason, file_count, is_directory) = if source.exists() {
let is_dir = source.is_dir();
let count = if is_dir {
count_files_with_progress(&source, |n| {
(ctx.on_progress)(*current_op, ctx.total_ops, &display_str, Some(n));
})
} else {
1
};
(false, None, count, is_dir)
} else {
(true, Some("not found".to_string()), 0, false)
};
operations.push(PlannedOperation {
display_path: display_str,
operation_type: OperationType::Overwrite,
source,
target,
file_count,
is_directory,
will_skip,
skip_reason,
force_overwrite: false,
});
}
operations
}
fn plan_glob_ops<F>(
ctx: &PlanContext<'_, F>,
current_op: &mut usize,
patterns: &[String],
) -> Result<Vec<PlannedOperation>, OperationError>
where
F: Fn(usize, usize, &str, Option<u64>),
{
let mut operations = Vec::new();
for pattern in patterns {
*current_op += 1;
let (search_dir, display_prefix, glob_pattern) = pattern.strip_prefix('/').map_or_else(
|| {
(
ctx.main_worktree.join(ctx.config_relative_dir),
ctx.config_relative_dir.to_path_buf(),
pattern.as_str(),
)
},
|stripped| (ctx.main_worktree.to_path_buf(), PathBuf::new(), stripped),
);
let full_pattern = search_dir.join(glob_pattern).to_string_lossy().to_string();
(ctx.on_progress)(*current_op, ctx.total_ops, pattern, None);
for entry in glob::glob(&full_pattern)? {
if let Ok(source) = entry
&& let Ok(rel_path) = source.strip_prefix(&search_dir)
{
let target = if pattern.starts_with('/') {
ctx.target_worktree.join(rel_path)
} else {
ctx.target_worktree
.join(ctx.config_relative_dir)
.join(rel_path)
};
let display_path = if display_prefix.as_os_str().is_empty() {
rel_path.to_path_buf()
} else {
display_prefix.join(rel_path)
};
let (will_skip, skip_reason, op_type) = if target.exists() {
if ctx.overwrite {
(false, None, OperationType::Overwrite)
} else {
(true, Some("exists".to_string()), OperationType::CopyGlob)
}
} else {
(false, None, OperationType::CopyGlob)
};
operations.push(PlannedOperation {
display_path: display_path.to_string_lossy().to_string(),
operation_type: op_type,
source,
target,
file_count: 1,
is_directory: false,
will_skip,
skip_reason,
force_overwrite: false,
});
}
}
}
Ok(operations)
}
fn plan_template_ops<F>(
ctx: &PlanContext<'_, F>,
current_op: &mut usize,
templates: &[worktree_setup_config::TemplateMapping],
) -> Vec<PlannedOperation>
where
F: Fn(usize, usize, &str, Option<u64>),
{
let mut operations = Vec::new();
for template in templates {
*current_op += 1;
let (source, source_display) =
resolve_path(ctx.main_worktree, ctx.config_relative_dir, &template.source);
let (target, target_display) = resolve_path(
ctx.target_worktree,
ctx.config_relative_dir,
&template.target,
);
let display_path = format!("{source_display} -> {target_display}");
(ctx.on_progress)(*current_op, ctx.total_ops, &display_path, None);
let (will_skip, skip_reason, op_type) = if !source.exists() {
(true, Some("not found".to_string()), OperationType::Template)
} else if target.exists() {
if ctx.overwrite {
(false, None, OperationType::Overwrite)
} else {
(true, Some("exists".to_string()), OperationType::Template)
}
} else {
(false, None, OperationType::Template)
};
operations.push(PlannedOperation {
display_path,
operation_type: op_type,
source,
target,
file_count: 1,
is_directory: false,
will_skip,
skip_reason,
force_overwrite: false,
});
}
operations
}
#[must_use]
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,
force_overwrite: false,
});
}
}
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);
}
#[test]
fn test_plan_operations_repo_root_relative_paths() {
let main_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::create_dir_all(main_dir.path().join(".nix")).unwrap();
fs::write(main_dir.path().join(".nix/flake.nix"), "{}").unwrap();
fs::write(main_dir.path().join(".envrc"), "use flake").unwrap();
let app_dir = main_dir.path().join("apps/myapp");
fs::create_dir_all(&app_dir).unwrap();
let config = LoadedConfig {
config: Config {
copy: vec!["/.nix".to_string(), "/.envrc".to_string()],
..Default::default()
},
config_path: app_dir.join("worktree.config.toml"),
config_dir: app_dir.clone(),
relative_path: "apps/myapp/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(), 2);
assert_eq!(ops[0].display_path, ".nix");
assert_eq!(ops[0].source, main_dir.path().join(".nix"));
assert_eq!(ops[0].target, target_dir.path().join(".nix"));
assert!(ops[0].is_directory);
assert!(!ops[0].will_skip);
assert_eq!(ops[1].display_path, ".envrc");
assert_eq!(ops[1].source, main_dir.path().join(".envrc"));
assert_eq!(ops[1].target, target_dir.path().join(".envrc"));
assert!(!ops[1].is_directory);
assert!(!ops[1].will_skip);
}
#[test]
fn test_plan_operations_mixed_paths() {
let main_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::write(main_dir.path().join(".envrc"), "use flake").unwrap();
let app_dir = main_dir.path().join("apps/myapp");
fs::create_dir_all(&app_dir).unwrap();
fs::write(app_dir.join("local.config"), "app config").unwrap();
let config = LoadedConfig {
config: Config {
copy: vec![
"/.envrc".to_string(), "local.config".to_string(), ],
..Default::default()
},
config_path: app_dir.join("worktree.config.toml"),
config_dir: app_dir.clone(),
relative_path: "apps/myapp/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(), 2);
assert_eq!(ops[0].display_path, ".envrc");
assert_eq!(ops[0].source, main_dir.path().join(".envrc"));
assert_eq!(ops[0].target, target_dir.path().join(".envrc"));
assert_eq!(ops[1].display_path, "apps/myapp/local.config");
assert_eq!(ops[1].source, app_dir.join("local.config"));
assert_eq!(
ops[1].target,
target_dir.path().join("apps/myapp/local.config")
);
}
#[test]
fn test_plan_operations_template_with_root_paths() {
let main_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
fs::write(main_dir.path().join(".env.template"), "KEY=value").unwrap();
let app_dir = main_dir.path().join("apps/myapp");
fs::create_dir_all(&app_dir).unwrap();
let config = LoadedConfig {
config: Config {
templates: vec![worktree_setup_config::TemplateMapping {
source: "/.env.template".to_string(), target: ".env.local".to_string(), }],
..Default::default()
},
config_path: app_dir.join("worktree.config.toml"),
config_dir: app_dir.clone(),
relative_path: "apps/myapp/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_eq!(ops[0].operation_type, OperationType::Template);
assert_eq!(
ops[0].display_path,
".env.template -> apps/myapp/.env.local"
);
assert_eq!(ops[0].source, main_dir.path().join(".env.template"));
assert_eq!(
ops[0].target,
target_dir.path().join("apps/myapp/.env.local")
);
}
}