use std::borrow::Cow;
use smol_str::SmolStr;
use crate::output::SkipReason;
use crate::picker::{ChoiceLabel, PickItem};
use super::{Plan, TomlPlan};
pub(super) const CHOICE_IMPORT: &str = "import";
pub(super) const CHOICE_RENAME: &str = "rename";
pub(super) const CHOICE_REMOVE: &str = "remove";
pub(super) const CHOICE_KEEP: &str = "keep";
pub(super) const CHOICE_SKIP: &str = "skip";
const FILE_CHOICES: &[ChoiceLabel] = &[
ChoiceLabel {
key: SmolStr::new_static(CHOICE_IMPORT),
label: SmolStr::new_static("import"),
},
ChoiceLabel {
key: SmolStr::new_static(CHOICE_SKIP),
label: SmolStr::new_static("skip"),
},
];
const RENAME_CHOICES: &[ChoiceLabel] = &[
ChoiceLabel {
key: SmolStr::new_static(CHOICE_RENAME),
label: SmolStr::new_static("rename"),
},
ChoiceLabel {
key: SmolStr::new_static(CHOICE_SKIP),
label: SmolStr::new_static("skip"),
},
];
const REMOVE_CHOICES: &[ChoiceLabel] = &[
ChoiceLabel {
key: SmolStr::new_static(CHOICE_REMOVE),
label: SmolStr::new_static("remove"),
},
ChoiceLabel {
key: SmolStr::new_static(CHOICE_KEEP),
label: SmolStr::new_static("keep"),
},
];
const SKIPPED_CHOICES: &[ChoiceLabel] = &[ChoiceLabel {
key: SmolStr::new_static(CHOICE_SKIP),
label: SmolStr::new_static("skip"),
}];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct PendingItem {
pub(super) kind: PendingKind,
pub(super) index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PendingKind {
File,
Renamed,
Removed,
Skipped,
}
pub(super) fn pickable_items(plan: &Plan) -> (Vec<PendingItem>, Vec<PickItem<'static>>) {
let mut pending = Vec::new();
let mut items = Vec::new();
for (idx, f) in plan.files.iter().enumerate() {
pending.push(PendingItem {
kind: PendingKind::File,
index: idx,
});
items.push(PickItem {
label: Cow::Owned(super::path_to_forward_slash(&f.rel)),
note: None,
choices: FILE_CHOICES,
choice: 0,
});
}
for (idx, r) in plan.renamed_files.iter().enumerate() {
pending.push(PendingItem {
kind: PendingKind::Renamed,
index: idx,
});
items.push(PickItem {
label: Cow::Owned(format!(
"{} → {}",
super::path_to_forward_slash(&r.from),
super::path_to_forward_slash(&r.to),
)),
note: Some(Cow::Borrowed("moved in place")),
choices: RENAME_CHOICES,
choice: 0,
});
}
for (idx, r) in plan.removed_files.iter().enumerate() {
pending.push(PendingItem {
kind: PendingKind::Removed,
index: idx,
});
items.push(PickItem {
label: Cow::Owned(super::path_to_forward_slash(&r.repo_rel)),
note: Some(Cow::Borrowed("gone from disk")),
choices: REMOVE_CHOICES,
choice: 0,
});
}
for (idx, s) in plan.skipped.iter().enumerate() {
pending.push(PendingItem {
kind: PendingKind::Skipped,
index: idx,
});
items.push(PickItem {
label: Cow::Owned(super::path_to_forward_slash(&s.path)),
note: Some(Cow::Borrowed(humanise_reason(s.reason))),
choices: SKIPPED_CHOICES,
choice: 0,
});
}
(pending, items)
}
fn humanise_reason(reason: SkipReason) -> &'static str {
match reason {
SkipReason::Symlink => "already symlinked",
SkipReason::Vcs => "vcs directory",
SkipReason::Other => "not a regular file",
SkipReason::PresentButNotLinked => "regular file shadows array entry",
SkipReason::SymlinkElsewhere => "symlink to outside the repo",
}
}
pub(super) fn apply_picks(plan: &mut Plan, pending: &[PendingItem], items: &[PickItem<'_>]) {
debug_assert_eq!(pending.len(), items.len());
let mut drop_files: Vec<usize> = Vec::new();
let mut drop_renamed: Vec<usize> = Vec::new();
let mut drop_removed: Vec<usize> = Vec::new();
for (p, item) in pending.iter().zip(items.iter()) {
let choice = &item.choices[item.choice];
match (p.kind, choice.key.as_str()) {
(PendingKind::File, CHOICE_SKIP) => drop_files.push(p.index),
(PendingKind::Renamed, CHOICE_SKIP) => drop_renamed.push(p.index),
(PendingKind::Removed, CHOICE_KEEP) => drop_removed.push(p.index),
_ => {}
}
}
drop_in_place(&mut plan.files, &drop_files);
drop_in_place(&mut plan.renamed_files, &drop_renamed);
drop_in_place(&mut plan.removed_files, &drop_removed);
rebuild_toml_deltas(plan);
}
fn drop_in_place<T>(v: &mut Vec<T>, indices: &[usize]) {
if indices.is_empty() {
return;
}
let mut sorted: Vec<usize> = indices.to_vec();
sorted.sort_unstable();
sorted.dedup();
for idx in sorted.into_iter().rev() {
v.remove(idx);
}
}
fn rebuild_toml_deltas(plan: &mut Plan) {
match &mut plan.toml {
TomlPlan::Reconcile {
added_symlinks,
removed_symlinks,
..
} => {
let mut new_added: Vec<String> = plan
.files
.iter()
.map(|f| super::path_to_forward_slash(&f.repo_rel))
.collect();
let mut new_removed: Vec<String> = plan
.removed_files
.iter()
.map(|r| super::path_to_forward_slash(&r.repo_rel))
.collect();
for r in &plan.renamed_files {
new_added.push(super::path_to_forward_slash(&r.to));
new_removed.push(super::path_to_forward_slash(&r.from));
}
*added_symlinks = new_added;
*removed_symlinks = new_removed;
}
TomlPlan::ExtendExisting {
existing_symlinks,
added_symlinks,
..
} => {
*added_symlinks = plan
.files
.iter()
.map(|f| super::path_to_forward_slash(&f.repo_rel))
.filter(|s| !existing_symlinks.iter().any(|e| e == s))
.collect();
}
TomlPlan::DotConfig { symlinks, .. } | TomlPlan::Home { symlinks, .. } => {
*symlinks = plan
.files
.iter()
.map(|f| super::path_to_forward_slash(&f.repo_rel))
.collect();
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use similar_asserts::assert_eq;
use smol_str::SmolStr;
use super::super::{PlannedFile, RemovedFile, RenamedFile, SkippedEntry};
use super::*;
fn empty_plan() -> Plan {
Plan {
pkg_key: SmolStr::new_static("p"),
r#type: crate::output::ImportType::DotConfig,
source: PathBuf::from("/src"),
source_root: PathBuf::from("/src"),
repo_dest: PathBuf::from("/repo"),
repo_rel: "configs/p".into(),
files: Vec::new(),
skipped: Vec::new(),
removed_files: Vec::new(),
renamed_files: Vec::new(),
toml: TomlPlan::DotConfig {
name_override: None,
symlinks: Vec::new(),
},
}
}
#[test]
fn pickable_items_orders_file_then_rename_then_remove_then_skipped() {
let mut plan = empty_plan();
plan.files.push(PlannedFile {
rel: PathBuf::from("a"),
repo_rel: PathBuf::from("a"),
});
plan.renamed_files.push(RenamedFile {
from: PathBuf::from("old"),
to: PathBuf::from("new"),
});
plan.removed_files.push(RemovedFile {
repo_rel: PathBuf::from("gone"),
});
plan.skipped.push(SkippedEntry {
path: PathBuf::from(".git"),
reason: SkipReason::Vcs,
});
let (pending, items) = pickable_items(&plan);
assert_eq!(pending.len(), 4);
assert_eq!(items.len(), 4);
assert_eq!(pending[0].kind, PendingKind::File);
assert_eq!(pending[1].kind, PendingKind::Renamed);
assert_eq!(pending[2].kind, PendingKind::Removed);
assert_eq!(pending[3].kind, PendingKind::Skipped);
assert_eq!(items[0].label, "a");
assert_eq!(items[1].label, "old → new");
assert_eq!(items[2].label, "gone");
assert_eq!(items[3].label, ".git");
assert_eq!(items[3].note.as_deref(), Some("vcs directory"));
assert_eq!(items[3].choices.len(), 1);
}
#[test]
fn humanise_reason_known_tags() {
assert_eq!(humanise_reason(SkipReason::Symlink), "already symlinked");
assert_eq!(humanise_reason(SkipReason::Vcs), "vcs directory");
assert_eq!(humanise_reason(SkipReason::Other), "not a regular file");
assert_eq!(
humanise_reason(SkipReason::PresentButNotLinked),
"regular file shadows array entry",
);
assert_eq!(
humanise_reason(SkipReason::SymlinkElsewhere),
"symlink to outside the repo",
);
}
#[test]
fn apply_picks_skip_on_file_drops_it_from_plan_files() {
let mut plan = empty_plan();
plan.files.push(PlannedFile {
rel: PathBuf::from("keep"),
repo_rel: PathBuf::from("keep"),
});
plan.files.push(PlannedFile {
rel: PathBuf::from("drop"),
repo_rel: PathBuf::from("drop"),
});
let (pending, mut items) = pickable_items(&plan);
items[1].choice = 1;
apply_picks(&mut plan, &pending, &items);
assert_eq!(plan.files.len(), 1);
assert_eq!(plan.files[0].rel, PathBuf::from("keep"));
match &plan.toml {
TomlPlan::DotConfig { symlinks, .. } => {
assert_eq!(symlinks, &vec!["keep".to_string()]);
}
other => panic!("unexpected toml plan: {other:?}"),
}
}
#[test]
fn apply_picks_keep_on_removed_drops_it_from_plan_removed_files() {
let mut plan = empty_plan();
plan.toml = TomlPlan::Reconcile {
config_index: 0,
existing_symlinks: vec!["a".into(), "b".into()],
added_symlinks: Vec::new(),
removed_symlinks: vec!["a".into(), "b".into()],
};
plan.removed_files.push(RemovedFile {
repo_rel: PathBuf::from("a"),
});
plan.removed_files.push(RemovedFile {
repo_rel: PathBuf::from("b"),
});
let (pending, mut items) = pickable_items(&plan);
items[0].choice = 1;
apply_picks(&mut plan, &pending, &items);
assert_eq!(plan.removed_files.len(), 1);
assert_eq!(plan.removed_files[0].repo_rel, PathBuf::from("b"));
match &plan.toml {
TomlPlan::Reconcile {
removed_symlinks, ..
} => {
assert_eq!(removed_symlinks, &vec!["b".to_string()]);
}
other => panic!("unexpected toml plan: {other:?}"),
}
}
#[test]
fn apply_picks_skip_on_renamed_drops_both_sides_from_deltas() {
let mut plan = empty_plan();
plan.toml = TomlPlan::Reconcile {
config_index: 0,
existing_symlinks: vec!["old".into()],
added_symlinks: vec!["new".into()],
removed_symlinks: vec!["old".into()],
};
plan.renamed_files.push(RenamedFile {
from: PathBuf::from("old"),
to: PathBuf::from("new"),
});
let (pending, mut items) = pickable_items(&plan);
items[0].choice = 1;
apply_picks(&mut plan, &pending, &items);
assert_eq!(plan.renamed_files.len(), 0);
match &plan.toml {
TomlPlan::Reconcile {
added_symlinks,
removed_symlinks,
..
} => {
assert!(added_symlinks.is_empty());
assert!(removed_symlinks.is_empty());
}
other => panic!("unexpected toml plan: {other:?}"),
}
}
#[test]
fn apply_picks_no_changes_leaves_plan_alone() {
let mut plan = empty_plan();
plan.files.push(PlannedFile {
rel: PathBuf::from("a"),
repo_rel: PathBuf::from("a"),
});
let (pending, items) = pickable_items(&plan);
apply_picks(&mut plan, &pending, &items);
assert_eq!(plan.files.len(), 1);
}
#[test]
fn drop_in_place_walks_back_to_front_with_dedup() {
let mut v = vec!['a', 'b', 'c', 'd', 'e'];
drop_in_place(&mut v, &[1, 3, 1]);
assert_eq!(v, vec!['a', 'c', 'e']);
}
}