use std::collections::BTreeMap;
use serde::Serialize;
use crate::config::{Expectation, Relation, SourceDef};
use crate::expand::normalize;
use crate::os_detect::Os;
use crate::path_entry::PathEntry;
use crate::source_match;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
pub struct EntryMove {
pub entry: String,
pub from: usize,
pub to: usize,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SortNote {
UnsatisfiablePrefer {
command: String,
prefer: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
pub struct SortPlan {
pub original: Vec<String>,
pub sorted: Vec<String>,
pub moves: Vec<EntryMove>,
pub notes: Vec<SortNote>,
}
impl SortPlan {
pub fn is_noop(&self) -> bool {
self.moves.is_empty() && self.original == self.sorted
}
}
pub fn sort_path(
entries: &[PathEntry],
expectations: &[Expectation],
sources: &BTreeMap<String, SourceDef>,
relations: &[Relation],
os: Os,
) -> SortPlan {
let original: Vec<String> = entries.iter().map(|e| e.raw.clone()).collect();
let entry_sources: Vec<Vec<String>> = entries
.iter()
.map(|e| source_match::names_only(&normalize(&e.expanded), sources, os))
.collect();
let intents = collect_intents(expectations, &entry_sources, os);
let preferred_set: std::collections::BTreeSet<usize> = intents
.iter()
.filter_map(|(i, intent, _)| matches!(intent, Intent::Prefer).then_some(*i))
.collect();
let avoided_set: std::collections::BTreeSet<usize> = intents
.iter()
.filter_map(|(i, intent, _)| matches!(intent, Intent::Avoid).then_some(*i))
.collect();
let preferred_idx = entries
.iter()
.enumerate()
.filter_map(|(i, _)| preferred_set.contains(&i).then_some(i));
let neutral_idx = entries.iter().enumerate().filter_map(|(i, _)| {
(!preferred_set.contains(&i) && !avoided_set.contains(&i)).then_some(i)
});
let avoided_idx = entries
.iter()
.enumerate()
.filter_map(|(i, _)| avoided_set.contains(&i).then_some(i));
let mut preferred_bucket: Vec<usize> = preferred_idx.collect();
let mut neutral_bucket: Vec<usize> = neutral_idx.collect();
let mut avoided_bucket: Vec<usize> = avoided_idx.collect();
apply_prefer_order_over(&mut preferred_bucket, &entry_sources, relations);
apply_prefer_order_over(&mut neutral_bucket, &entry_sources, relations);
apply_prefer_order_over(&mut avoided_bucket, &entry_sources, relations);
let new_order: Vec<usize> = preferred_bucket
.into_iter()
.chain(neutral_bucket)
.chain(avoided_bucket)
.collect();
let sorted: Vec<String> = new_order.iter().map(|&i| entries[i].raw.clone()).collect();
let moves: Vec<EntryMove> = new_order
.iter()
.copied()
.enumerate()
.filter(|(new_idx, old_idx)| new_idx != old_idx)
.map(|(new_idx, old_idx)| EntryMove {
entry: entries[old_idx].raw.clone(),
from: old_idx,
to: new_idx,
reason: reason_for(old_idx, &intents),
})
.collect();
let notes = collect_notes(expectations, &entry_sources, os);
SortPlan {
original,
sorted,
moves,
notes,
}
}
fn apply_prefer_order_over(
bucket: &mut [usize],
entry_sources: &[Vec<String>],
relations: &[Relation],
) {
let order_pairs: Vec<(&str, &str)> = crate::catalog::RelationIndex::from_slice(relations)
.iter_prefer_orders()
.collect();
if order_pairs.is_empty() {
return;
}
let mut moved = true;
let mut guard = bucket.len() * bucket.len() + 1;
while moved && guard > 0 {
moved = false;
guard -= 1;
for i in 0..bucket.len().saturating_sub(1) {
for j in (i + 1)..bucket.len() {
let i_sources = &entry_sources[bucket[i]];
let j_sources = &entry_sources[bucket[j]];
let violates = order_pairs.iter().any(|(earlier, later)| {
let i_is_later = i_sources.iter().any(|s| s.as_str() == *later);
let j_is_earlier = j_sources.iter().any(|s| s.as_str() == *earlier);
i_is_later && j_is_earlier
});
if violates {
bucket.swap(i, j);
moved = true;
break;
}
}
if moved {
break;
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Intent {
Prefer,
Avoid,
}
fn collect_intents(
expectations: &[Expectation],
entry_sources: &[Vec<String>],
os: Os,
) -> Vec<(usize, Intent, String)> {
let mut out = Vec::new();
for expect in expectations {
if !crate::os_detect::os_filter_applies(&expect.os, os) {
continue;
}
for (i, srcs) in entry_sources.iter().enumerate() {
if srcs.iter().any(|s| expect.avoid.iter().any(|a| a == s)) {
out.push((i, Intent::Avoid, expect.command.clone()));
} else if srcs.iter().any(|s| expect.prefer.iter().any(|p| p == s)) {
out.push((i, Intent::Prefer, expect.command.clone()));
}
}
}
out
}
fn reason_for(old_idx: usize, intents: &[(usize, Intent, String)]) -> String {
let avoid_hit = intents
.iter()
.find(|(i, intent, _)| *i == old_idx && matches!(intent, Intent::Avoid));
if let Some((_, _, cmd)) = avoid_hit {
return format!("matches `avoid` for `{cmd}`");
}
let prefer_hit = intents
.iter()
.find(|(i, intent, _)| *i == old_idx && matches!(intent, Intent::Prefer));
if let Some((_, _, cmd)) = prefer_hit {
return format!("preferred source for `{cmd}`");
}
"displaced by a preferred entry".to_string()
}
fn collect_notes(
expectations: &[Expectation],
entry_sources: &[Vec<String>],
os: Os,
) -> Vec<SortNote> {
expectations
.iter()
.filter(|expect| crate::os_detect::os_filter_applies(&expect.os, os))
.filter(|expect| !expect.prefer.is_empty())
.filter(|expect| {
!entry_sources
.iter()
.any(|srcs| srcs.iter().any(|s| expect.prefer.iter().any(|p| p == s)))
})
.map(|expect| SortNote::UnsatisfiablePrefer {
command: expect.command.clone(),
prefer: expect.prefer.clone(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn src(unix: &str) -> SourceDef {
SourceDef {
unix: Some(unix.into()),
..Default::default()
}
}
fn cat(entries: &[(&str, SourceDef)]) -> BTreeMap<String, SourceDef> {
entries
.iter()
.map(|(n, d)| (n.to_string(), d.clone()))
.collect()
}
fn entries(s: &[&str]) -> Vec<PathEntry> {
s.iter()
.map(|x| PathEntry::from_raw(*x, |_| -> Option<String> { None }))
.collect()
}
fn expect_simple(command: &str, prefer: &[&str]) -> Expectation {
Expectation {
command: command.into(),
prefer: prefer.iter().map(|s| s.to_string()).collect(),
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}
}
#[test]
fn empty_input_produces_noop_plan() {
let plan = sort_path(&[], &[], &BTreeMap::new(), &[], Os::Linux);
assert!(plan.is_noop());
assert_eq!(plan.original, plan.sorted);
assert!(plan.moves.is_empty());
assert!(plan.notes.is_empty());
}
#[test]
fn already_sorted_path_is_noop() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("usr_bin", src("/usr/bin")),
]);
let path = entries(&["/home/u/.cargo/bin", "/usr/bin"]);
let expects = vec![expect_simple("rg", &["cargo"])];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
assert!(plan.is_noop(), "plan was: {plan:?}");
}
#[test]
fn out_of_order_path_proposes_swap() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("usr_bin", src("/usr/bin")),
]);
let path = entries(&["/usr/bin", "/home/u/.cargo/bin"]);
let expects = vec![expect_simple("rg", &["cargo"])];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
assert!(!plan.is_noop());
assert_eq!(
plan.sorted,
vec!["/home/u/.cargo/bin".to_string(), "/usr/bin".to_string(),]
);
assert_eq!(plan.moves.len(), 2);
let cargo_move = &plan
.moves
.iter()
.find(|m| m.entry.contains("cargo"))
.unwrap();
assert_eq!(cargo_move.from, 1);
assert_eq!(cargo_move.to, 0);
assert!(
cargo_move.reason.contains("rg"),
"reason: {}",
cargo_move.reason
);
}
#[test]
fn unsatisfiable_prefer_emits_note_without_reordering() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("usr_bin", src("/usr/bin")),
]);
let path = entries(&["/usr/bin", "/usr/local/bin"]);
let expects = vec![expect_simple("rg", &["cargo"])];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
assert!(plan.is_noop());
assert_eq!(plan.notes.len(), 1);
match &plan.notes[0] {
SortNote::UnsatisfiablePrefer { command, prefer } => {
assert_eq!(command, "rg");
assert_eq!(prefer, &vec!["cargo".to_string()]);
}
}
}
#[test]
fn os_filter_excluded_rules_contribute_nothing() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("usr_bin", src("/usr/bin")),
]);
let path = entries(&["/usr/bin", "/home/u/.cargo/bin"]);
let mut e = expect_simple("rg", &["cargo"]);
e.os = Some(vec!["windows".into()]);
let plan = sort_path(&path, &[e], &sources, &[], Os::Linux);
assert!(plan.is_noop());
}
#[test]
fn preferred_entries_keep_relative_order_among_themselves() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("mise_shims", src("/home/u/.local/share/mise/shims")),
("usr_bin", src("/usr/bin")),
]);
let path = entries(&[
"/usr/bin",
"/home/u/.cargo/bin",
"/home/u/.local/share/mise/shims",
]);
let expects = vec![
expect_simple("rg", &["cargo"]),
expect_simple("python", &["mise_shims"]),
];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
let cargo_pos = plan
.sorted
.iter()
.position(|e| e.contains("cargo"))
.unwrap();
let shims_pos = plan
.sorted
.iter()
.position(|e| e.contains("shims"))
.unwrap();
let usr_pos = plan.sorted.iter().position(|e| e == "/usr/bin").unwrap();
assert!(cargo_pos < shims_pos, "cargo should precede shims");
assert!(
shims_pos < usr_pos,
"preferred entries should precede /usr/bin"
);
}
#[test]
fn entries_outside_any_source_keep_their_position() {
let sources = cat(&[("cargo", src("/home/u/.cargo/bin"))]);
let path = entries(&["/opt/custom", "/home/u/.cargo/bin"]);
let expects = vec![expect_simple("rg", &["cargo"])];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
assert_eq!(plan.sorted[0], "/home/u/.cargo/bin");
assert_eq!(plan.sorted[1], "/opt/custom");
}
fn expect_prefer_avoid(command: &str, prefer: &[&str], avoid: &[&str]) -> Expectation {
Expectation {
command: command.into(),
prefer: prefer.iter().map(|s| s.to_string()).collect(),
avoid: avoid.iter().map(|s| s.to_string()).collect(),
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}
}
#[test]
fn avoid_only_demotes_matching_entry_to_the_back() {
let sources = cat(&[
("winget", src("/winget/links")),
("plain", src("/usr/local/bin")),
]);
let path = entries(&["/winget/links", "/usr/local/bin"]);
let expects = vec![expect_prefer_avoid("rg", &[], &["winget"])];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
assert_eq!(
plan.sorted,
vec!["/usr/local/bin".to_string(), "/winget/links".to_string(),]
);
}
#[test]
fn avoid_wins_when_entry_matches_both_prefer_and_avoid() {
let sources = cat(&[
("mise", src("/home/u/.local/share/mise")),
(
"dangerous",
src("/home/u/.local/share/mise/installs/python/3.10"),
),
("plain", src("/usr/bin")),
]);
let path = entries(&[
"/home/u/.local/share/mise/installs/python/3.10/bin",
"/usr/bin",
]);
let expects = vec![expect_prefer_avoid("python", &["mise"], &["dangerous"])];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
assert_eq!(plan.sorted[0], "/usr/bin");
assert!(plan.sorted[1].contains("dangerous") || plan.sorted[1].contains("python/3.10"));
}
#[test]
fn avoid_with_no_match_is_silent() {
let sources = cat(&[
("winget", src("/winget/links")),
("cargo", src("/home/u/.cargo/bin")),
]);
let path = entries(&["/home/u/.cargo/bin", "/usr/bin"]);
let expects = vec![expect_prefer_avoid("rg", &["cargo"], &["winget"])];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
assert!(plan.is_noop(), "plan was: {plan:?}");
}
#[test]
fn prefer_promotes_above_avoid_in_three_way_layout() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("winget", src("/winget/links")),
("plain", src("/usr/bin")),
]);
let path = entries(&["/winget/links", "/usr/bin", "/home/u/.cargo/bin"]);
let expects = vec![expect_prefer_avoid("rg", &["cargo"], &["winget"])];
let plan = sort_path(&path, &expects, &sources, &[], Os::Linux);
assert_eq!(
plan.sorted,
vec![
"/home/u/.cargo/bin".to_string(),
"/usr/bin".to_string(),
"/winget/links".to_string(),
]
);
}
#[test]
fn prefer_order_over_reorders_neutral_bucket() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("usr_bin", src("/usr/bin")),
]);
let path = entries(&["/usr/bin", "/home/u/.cargo/bin"]);
let relations = vec![Relation::PreferOrderOver {
earlier: "cargo".into(),
later: "usr_bin".into(),
}];
let plan = sort_path(&path, &[], &sources, &relations, Os::Linux);
assert_eq!(
plan.sorted,
vec!["/home/u/.cargo/bin".to_string(), "/usr/bin".to_string()],
);
}
#[test]
fn prefer_order_over_does_not_cross_buckets() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("usr_bin", src("/usr/bin")),
]);
let path = entries(&["/home/u/.cargo/bin", "/usr/bin"]);
let expects = vec![expect_prefer_avoid("rg", &["usr_bin"], &["cargo"])];
let relations = vec![Relation::PreferOrderOver {
earlier: "cargo".into(),
later: "usr_bin".into(),
}];
let plan = sort_path(&path, &expects, &sources, &relations, Os::Linux);
assert_eq!(
plan.sorted,
vec!["/usr/bin".to_string(), "/home/u/.cargo/bin".to_string()],
"preferred bucket beats prefer_order_over"
);
}
#[test]
fn prefer_order_over_with_no_relations_matches_pre_0_0_10() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("usr_bin", src("/usr/bin")),
]);
let path = entries(&["/usr/bin", "/home/u/.cargo/bin"]);
let plan = sort_path(&path, &[], &sources, &[], Os::Linux);
assert!(plan.is_noop());
assert_eq!(plan.original, plan.sorted);
}
}