Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ fn main() {
search_root,
ignore,
dry_run,
batch_confirm,
} = Args::parse();

let path = if path.as_os_str() == "." {
Expand Down Expand Up @@ -49,7 +50,7 @@ fn main() {
}

let mut io = TerminalIO;
match run(&cases, &mut io, dry_run) {
match run(&cases, &mut io, dry_run, batch_confirm) {
Ok(summary) => {
if summary.total() > 0 {
println!("{summary}");
Expand Down Expand Up @@ -82,4 +83,8 @@ struct Args {
/// Preview changes without modifying the filesystem
#[arg(short, long)]
dry_run: bool,

/// Collect all decisions, show summary, then confirm before applying
#[arg(short, long)]
batch_confirm: bool,
}
25 changes: 21 additions & 4 deletions core/src/fuzzy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ fn dir_components(path: &Path) -> Vec<String> {
.collect()
}

fn score_candidate(broken: &BrokenSymlink, candidate: &Path, search_root: &Path) -> f64 {
fn score_candidate(broken: &BrokenSymlink, candidate: &Path, search_root: &Path) -> (f64, usize) {
let target_name = broken
.target
.file_name()
Expand Down Expand Up @@ -77,7 +77,8 @@ fn score_candidate(broken: &BrokenSymlink, candidate: &Path, search_root: &Path)
.unwrap_or(0);
let depth_penalty = depth as f64 * 0.1;

filename_score * 10.0 + path_score * 3.0 + depth_penalty
let score = filename_score * 10.0 + path_score * 3.0 + depth_penalty;
(score, shared)
}

pub fn find_candidates(
Expand Down Expand Up @@ -123,11 +124,25 @@ pub fn find_candidates(
})
.map(|e| {
let path = e.into_path();
let score = score_candidate(broken, &path, search_root);
ScoredCandidate { path, score }
let (score, shared_dirs) = score_candidate(broken, &path, search_root);
ScoredCandidate {
path,
score,
shared_dirs,
basename_count: 0,
}
})
.collect();

let target_name = broken.target.file_name();
let basename_count = candidates
.iter()
.filter(|c| c.path.file_name() == target_name)
.count();
for c in &mut candidates {
c.basename_count = basename_count;
}

candidates.sort_by(|a, b| {
a.score
.partial_cmp(&b.score)
Expand All @@ -140,6 +155,8 @@ pub fn find_candidates(
pub struct ScoredCandidate {
pub path: PathBuf,
pub score: f64,
pub shared_dirs: usize,
pub basename_count: usize,
}

pub const DEFAULT_IGNORE: &[&str] = &[
Expand Down
4 changes: 4 additions & 0 deletions core/src/resolver/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ mod tests {
ScoredCandidate {
path: "/first/candidate.txt".into(),
score: 1.0,
shared_dirs: 0,
basename_count: 2,
},
ScoredCandidate {
path: "/second/candidate.txt".into(),
score: 2.0,
shared_dirs: 0,
basename_count: 2,
},
],
)
Expand Down
45 changes: 39 additions & 6 deletions core/src/resolver/display.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use std::fmt;

use super::model::RepairCase;
use super::{
model::RepairCase,
safety::{format_warnings, relink_warnings},
};

pub fn present(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Result {
format_header(w, case)?;
Expand All @@ -18,18 +21,40 @@ pub fn format_header(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Result
}

pub fn format_candidates(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Result {
let RepairCase { ref candidates, .. } = *case;
let RepairCase {
ref candidates,
ref original_target,
..
} = *case;
if candidates.is_empty() {
writeln!(w, " no candidates found")
} else {
let target_basename = original_target
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
for (i, candidate) in candidates.iter().enumerate() {
let basename_note = if candidate.basename_count <= 1 {
"only match".to_string()
} else {
format!(
"{} files named {target_basename} found",
candidate.basename_count
)
};
writeln!(
w,
" [{}] {} (score: {:.2})",
" [{}] {} (score: {:.2}, {} shared dirs, {})",
i + 1,
candidate.path.display(),
candidate.score
candidate.score,
candidate.shared_dirs,
basename_note
)?;
let warnings = relink_warnings(&case.link, original_target, &candidate.path);
if !warnings.is_empty() {
write!(w, "{}", format_warnings(&warnings))?;
}
}
Ok(())
}
Expand Down Expand Up @@ -62,10 +87,14 @@ mod tests {
ScoredCandidate {
path: "/home/user/target.txt".into(),
score: 3.20,
shared_dirs: 0,
basename_count: 2,
},
ScoredCandidate {
path: "/archive/target.txt".into(),
score: 4.50,
shared_dirs: 0,
basename_count: 2,
},
],
)
Expand All @@ -82,6 +111,8 @@ mod tests {
vec![ScoredCandidate {
path: "/home/user/target.txt".into(),
score: 3.20,
shared_dirs: 0,
basename_count: 1,
}],
)
}
Expand All @@ -97,8 +128,10 @@ mod tests {
fn candidates_listed_with_scores() {
let mut out = String::new();
format_candidates(&mut out, &case_with_candidates()).unwrap();
assert!(out.contains("[1] /home/user/target.txt (score: 3.20)"));
assert!(out.contains("[2] /archive/target.txt (score: 4.50)"));
assert!(out.contains("[1] /home/user/target.txt (score: 3.20"));
assert!(out.contains("[2] /archive/target.txt (score: 4.50"));
assert!(out.contains("shared dirs"));
assert!(out.contains("files named target.txt found"));
}

#[test]
Expand Down
34 changes: 33 additions & 1 deletion core/src/resolver/fs_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::{
path::{Path, PathBuf},
};

use super::model::Action;
use super::{model::Action, safety::would_create_loop};

pub fn execute(link: &Path, action: &Action, dry_run: bool) -> Result<(), FsError> {
match action {
Expand All @@ -18,6 +18,12 @@ pub fn execute(link: &Path, action: &Action, dry_run: bool) -> Result<(), FsErro
})
}
Action::Relink(target) => {
if would_create_loop(link, target) {
return Err(FsError::WouldCreateLoop {
link: link.to_path_buf(),
target: target.clone(),
});
}
if dry_run {
return Ok(());
}
Expand Down Expand Up @@ -52,6 +58,14 @@ impl fmt::Display for FsError {
target.display()
)
}
Self::WouldCreateLoop { link, target } => {
write!(
f,
"refused: relinking {} -> {} would create a symlink loop",
link.display(),
target.display()
)
}
}
}
}
Expand All @@ -67,6 +81,10 @@ pub enum FsError {
target: PathBuf,
source: std::io::Error,
},
WouldCreateLoop {
link: PathBuf,
target: PathBuf,
},
}

#[cfg(test)]
Expand Down Expand Up @@ -157,6 +175,20 @@ mod tests {
assert!(msg.contains("not found"));
}

#[test]
fn would_create_loop_refused() {
let temp = TempDir::new().unwrap();
let a = temp.path().join("a");
let b = temp.path().join("b");
symlink(&b, &a).unwrap();
symlink(&a, &b).unwrap();

let result = execute(&a, &Action::Relink(b), false);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("loop"));
}

#[test]
fn error_display_symlink_includes_both_paths() {
let err = FsError::SymlinkFailed {
Expand Down
1 change: 1 addition & 0 deletions core/src/resolver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub(crate) mod fs_ops;
pub(crate) mod input;
pub mod io;
pub mod model;
pub(crate) mod safety;
pub(crate) mod session;

pub use display::present;
Expand Down
4 changes: 3 additions & 1 deletion core/src/resolver/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub enum Action {
Skip,
}

#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
pub struct Summary {
pub relinked: usize,
pub removed: usize,
Expand Down Expand Up @@ -115,6 +115,8 @@ mod tests {
vec![ScoredCandidate {
path: "/candidate".into(),
score: 1.0,
shared_dirs: 0,
basename_count: 1,
}],
);
assert!(case.has_candidates());
Expand Down
Loading
Loading