use crate::model::*;
use crate::renumber::renumber_new_side;
use crate::split::auto_split_hunk;
use crate::split::slice_changed_lines;
use crate::subhunk_id::subhunk_hash;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::ffi::OsStr;
use std::fmt;
#[derive(Debug, PartialEq, Eq)]
pub enum Selector {
File {
path: Option<Vec<u8>>,
indices: IndexSet,
},
Id(String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum IndexSet {
All,
List(Vec<usize>),
LineSet {
index: usize,
lines: Vec<usize>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum SelectError {
BadSelector(String),
UnknownPath(String),
AmbiguousPath(String),
NoIndex(String),
UnknownId(String),
IdCollision(String),
EmptySelection,
RemovedRangeForm(String),
LineSelect(String),
}
impl fmt::Display for SelectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SelectError::BadSelector(s) => write!(f, "bad selector: {s}"),
SelectError::UnknownPath(p) => write!(f, "no file in the diff matches path: {p}"),
SelectError::AmbiguousPath(p) => write!(f, "path matches more than one file: {p}"),
SelectError::NoIndex(s) => write!(f, "no such sub-hunk: {s}"),
SelectError::UnknownId(id) => write!(f, "no sub-hunk has id: {id}"),
SelectError::IdCollision(id) => write!(
f,
"id {id} collides between distinct sub-hunks; address them by path:N instead"
),
SelectError::EmptySelection => write!(f, "selection is empty"),
SelectError::RemovedRangeForm(s) => write!(
f,
"{s}: the @lo-hi added-line range form was removed; use @L<lines> instead \
(changed-line indices, e.g. @L1-3; see 'hunkpick list --json' changed_lines)"
),
SelectError::LineSelect(m) => write!(f, "line selector: {m}"),
}
}
}
impl std::error::Error for SelectError {}
pub fn build_file_subs(f: &FileDiff) -> Vec<Hunk> {
match &f.content {
FileContent::Text(hunks) => {
let mut subs = Vec::with_capacity(hunks.len());
for h in hunks {
subs.extend(auto_split_hunk(h));
}
subs
}
FileContent::Binary(_) => Vec::new(),
}
}
pub fn build_view(patch: &Patch) -> Vec<Vec<Hunk>> {
patch.files.iter().map(build_file_subs).collect()
}
pub fn parse_selectors<S: AsRef<OsStr>>(args: &[S]) -> Result<Vec<Selector>, SelectError> {
let mut out = Vec::new();
for arg in args {
let bytes = os_bytes(arg.as_ref());
let Ok(a) = std::str::from_utf8(bytes) else {
out.push(parse_binary_path_form(bytes)?);
continue;
};
if let Some(sel) = parse_path_form(a)? {
out.push(sel);
continue;
}
if let Some(id) = a.strip_prefix('@') {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SelectError::BadSelector(a.to_string()));
}
out.push(Selector::Id(id.to_string()));
continue;
}
let indices = parse_index_set(a).map_err(|e| match e {
SetParseError::RemovedRange => SelectError::RemovedRangeForm(a.to_string()),
e => SelectError::BadSelector(format!("{a} ({e})")),
})?;
out.push(Selector::File {
path: None,
indices,
});
}
Ok(out)
}
fn parse_path_form(arg: &str) -> Result<Option<Selector>, SelectError> {
let Some((path, set)) = arg.rsplit_once(':') else {
return Ok(None);
};
if path.is_empty() {
return Ok(None);
}
match parse_index_set(set) {
Ok(indices) => Ok(Some(Selector::File {
path: Some(path.as_bytes().to_vec()),
indices,
})),
Err(SetParseError::RemovedRange) => Err(SelectError::RemovedRangeForm(arg.to_string())),
Err(e) if looks_like_index_set(set) => {
Err(SelectError::BadSelector(format!("{arg} ({e})")))
}
Err(_) => Ok(None),
}
}
fn os_bytes(arg: &OsStr) -> &[u8] {
arg.as_encoded_bytes()
}
fn parse_binary_path_form(bytes: &[u8]) -> Result<Selector, SelectError> {
let shown = String::from_utf8_lossy(bytes).into_owned();
let Some(colon) = bytes.iter().rposition(|&b| b == b':') else {
return Err(SelectError::BadSelector(format!(
"{shown} (not valid UTF-8; only a path:set selector may hold such bytes)"
)));
};
let (path, set) = (&bytes[..colon], &bytes[colon + 1..]);
let Ok(set) = std::str::from_utf8(set) else {
return Err(SelectError::BadSelector(format!(
"{shown} (the set after ':' must be ASCII)"
)));
};
let indices = parse_index_set(set).map_err(|e| match e {
SetParseError::RemovedRange => SelectError::RemovedRangeForm(shown.clone()),
e => SelectError::BadSelector(format!("{shown} ({e})")),
})?;
Ok(Selector::File {
path: Some(path.to_vec()),
indices,
})
}
fn looks_like_index_set(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_digit() || matches!(c, ',' | '-' | '*' | '@' | 'L' | 'l'))
}
#[derive(Debug, PartialEq, Eq)]
enum SetParseError {
Empty,
NotANumber(String),
ZeroBound,
ReversedRange {
lo: usize,
hi: usize,
},
TooLarge,
RemovedRange,
}
impl fmt::Display for SetParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SetParseError::Empty => write!(f, "empty index set"),
SetParseError::NotANumber(s) => write!(f, "not a number: {s}"),
SetParseError::ZeroBound => write!(f, "indices are 1-based, 0 is not valid"),
SetParseError::ReversedRange { lo, hi } => write!(f, "reversed range: {lo}-{hi}"),
SetParseError::TooLarge => write!(
f,
"range too large: at most {MAX_SELECTOR_INDICES} indices per selector"
),
SetParseError::RemovedRange => write!(f, "the @lo-hi range form was removed; use @L"),
}
}
}
fn parse_index_set(s: &str) -> Result<IndexSet, SetParseError> {
if s == "*" {
return Ok(IndexSet::All);
}
if let Some((idx, rest)) = s.split_once('@') {
let index = parse_pos(idx)?;
let Some(set) = rest.strip_prefix('L') else {
return Err(SetParseError::RemovedRange);
};
let lines = parse_index_list(set)?;
return Ok(IndexSet::LineSet { index, lines });
}
parse_index_list(s).map(IndexSet::List)
}
fn parse_pos(s: &str) -> Result<usize, SetParseError> {
let n: usize = s
.parse()
.map_err(|_| SetParseError::NotANumber(s.to_string()))?;
if n == 0 {
Err(SetParseError::ZeroBound)
} else {
Ok(n)
}
}
const MAX_SELECTOR_INDICES: usize = 1 << 20;
fn parse_index_list(s: &str) -> Result<Vec<usize>, SetParseError> {
if s.is_empty() {
return Err(SetParseError::Empty);
}
let mut v = Vec::new();
for part in s.split(',') {
if let Some((lo_s, hi_s)) = part.split_once('-') {
let lo = parse_pos(lo_s)?;
let hi = parse_pos(hi_s)?;
if hi < lo {
return Err(SetParseError::ReversedRange { lo, hi });
}
let span = hi - lo + 1;
if span > MAX_SELECTOR_INDICES || v.len() + span > MAX_SELECTOR_INDICES {
return Err(SetParseError::TooLarge);
}
v.extend(lo..=hi);
} else {
if v.len() + 1 > MAX_SELECTOR_INDICES {
return Err(SetParseError::TooLarge);
}
v.push(parse_pos(part)?);
}
}
Ok(v)
}
pub(crate) fn all_same_content(items: &[(&FileDiff, &Hunk)]) -> bool {
fn same_changed(a: &Hunk, b: &Hunk) -> bool {
a.changed_lines()
.map(|(_, l)| l)
.eq(b.changed_lines().map(|(_, l)| l))
}
let Some(((first_file, first_sub), rest)) = items.split_first() else {
return true;
};
rest.iter().all(|(f, s)| {
f.new_path == first_file.new_path
&& f.old_path == first_file.old_path
&& same_changed(s, first_sub)
})
}
#[derive(Clone)]
enum Chosen {
Whole(usize),
Lines {
index: usize,
lines: Vec<usize>,
},
}
impl Chosen {
fn index(&self) -> usize {
match self {
Chosen::Whole(i) => *i,
Chosen::Lines { index, .. } => *index,
}
}
}
fn display_name(patch: &Patch, fi: usize, path: Option<&[u8]>) -> String {
path.map(|p| String::from_utf8_lossy(p).into_owned())
.unwrap_or_else(|| patch.files[fi].display_path())
}
pub fn select(patch: &Patch, selectors: &[Selector]) -> Result<Patch, SelectError> {
let mut subs_cache: BTreeMap<usize, Vec<Hunk>> = BTreeMap::new();
let chosen = resolve_selectors(patch, selectors, &mut subs_cache)?;
if chosen.is_empty() {
return Err(SelectError::EmptySelection);
}
let mut out = emit_selection(patch, chosen, &subs_cache)?;
renumber_new_side(&mut out);
Ok(out)
}
fn resolve_selectors(
patch: &Patch,
selectors: &[Selector],
subs_cache: &mut BTreeMap<usize, Vec<Hunk>>,
) -> Result<BTreeMap<usize, Vec<Chosen>>, SelectError> {
let mut chosen: BTreeMap<usize, Vec<Chosen>> = BTreeMap::new();
let paths = PathIndex::new(patch);
let mut ids: Option<IdIndex> = None;
for sel in selectors {
match sel {
Selector::Id(id) => {
let index = ids.get_or_insert_with(|| IdIndex::new(patch, subs_cache));
resolve_id(patch, id, subs_cache, index, &mut chosen)?
}
Selector::File { path, indices } => resolve_file_selector(
patch,
&paths,
path.as_deref(),
indices,
subs_cache,
&mut chosen,
)?,
}
}
Ok(chosen)
}
fn resolve_file_selector(
patch: &Patch,
paths: &PathIndex<'_>,
path: Option<&[u8]>,
indices: &IndexSet,
subs_cache: &mut BTreeMap<usize, Vec<Hunk>>,
chosen: &mut BTreeMap<usize, Vec<Chosen>>,
) -> Result<(), SelectError> {
let fi = paths.resolve(path)?;
if matches!(patch.files[fi].content, FileContent::Binary(_)) {
if let IndexSet::LineSet { .. } = indices {
return Err(SelectError::LineSelect(format!(
"{} is a binary file",
patch.files[fi].display_path()
)));
}
chosen.entry(fi).or_default();
return Ok(());
}
let subs = subs_cache
.entry(fi)
.or_insert_with(|| build_file_subs(&patch.files[fi]));
match indices {
IndexSet::All => {
let picks = chosen.entry(fi).or_default();
picks.extend((1..=subs.len()).map(Chosen::Whole));
}
IndexSet::List(v) => {
let picks = chosen.entry(fi).or_default();
for &idx in v {
if idx > subs.len() {
return Err(SelectError::NoIndex(format!(
"{}:{idx}",
display_name(patch, fi, path)
)));
}
picks.push(Chosen::Whole(idx));
}
}
IndexSet::LineSet { index, lines } => {
if *index > subs.len() {
return Err(SelectError::NoIndex(format!(
"{}:{index}",
display_name(patch, fi, path)
)));
}
chosen.entry(fi).or_default().push(Chosen::Lines {
index: *index,
lines: lines.clone(),
});
}
}
Ok(())
}
fn reject_conflicting_line_set_picks(picks: &[Chosen]) -> Result<(), SelectError> {
let mut times_picked: BTreeMap<usize, usize> = BTreeMap::new();
for p in picks {
*times_picked.entry(p.index()).or_insert(0) += 1;
}
for p in picks {
if let Chosen::Lines { index, .. } = p {
if times_picked[index] > 1 {
return Err(SelectError::LineSelect(format!(
"sub-hunk {index} is addressed by @L together with another \
selection of the same sub-hunk; address it once, or stage the \
pieces in separate rounds"
)));
}
}
}
Ok(())
}
fn reject_partial_selection_of_a_deleted_file(
f: &FileDiff,
hunks: &[Hunk],
) -> Result<(), SelectError> {
let declares_deletion = f.new_path.as_deref() == Some(b"/dev/null".as_slice())
|| f.headers
.iter()
.any(|h| h.starts_with(b"deleted file mode"));
if !declares_deletion {
return Ok(());
}
if hunks
.iter()
.any(|h| h.lines.iter().any(|l| l.kind == LineKind::Context))
{
return Err(SelectError::LineSelect(format!(
"{}: the entry deletes the file as a whole, so a partial @L selection cannot be \
expressed as one diff; select the sub-hunk whole, or stage the removal on its own",
String::from_utf8_lossy(f.old_path.as_deref().unwrap_or(b"?"))
)));
}
Ok(())
}
fn materialise_picks(subs: &[Hunk], picks: &[Chosen]) -> Result<Vec<Hunk>, SelectError> {
let mut hunks = Vec::with_capacity(picks.len());
for pick in picks {
match pick {
Chosen::Whole(i) => hunks.push(subs[i - 1].clone()),
Chosen::Lines { index, lines } => {
let set: BTreeSet<usize> = lines.iter().copied().collect();
let cut = slice_changed_lines(&subs[index - 1], &set)
.map_err(|e| SelectError::LineSelect(e.to_string()))?;
hunks.push(cut);
}
}
}
Ok(hunks)
}
fn emit_selection(
patch: &Patch,
chosen: BTreeMap<usize, Vec<Chosen>>,
subs_cache: &BTreeMap<usize, Vec<Hunk>>,
) -> Result<Patch, SelectError> {
let mut files = Vec::new();
let mut last_fi = None;
let mut ends_on_the_files_last_line = false;
for (fi, mut picks) in chosen {
last_fi = Some(fi);
let src = &patch.files[fi];
let content = match &src.content {
FileContent::Binary(b) => {
ends_on_the_files_last_line = true;
FileContent::Binary(b.clone())
}
FileContent::Text(_) => {
reject_conflicting_line_set_picks(&picks)?;
picks.sort_by_key(|c| c.index());
picks.dedup_by(
|a, b| matches!((a, b), (Chosen::Whole(x), Chosen::Whole(y)) if x == y),
);
let subs = &subs_cache[&fi];
ends_on_the_files_last_line =
matches!(picks.last(), Some(Chosen::Whole(i)) if *i == subs.len());
let hunks = materialise_picks(subs, &picks)?;
reject_partial_selection_of_a_deleted_file(src, &hunks)?;
FileContent::Text(hunks)
}
};
let src_hunks = src.hunk_count();
let out_hunks = content.hunk_count();
let trailer: Vec<_> = src
.trailer
.iter()
.filter(|(at, _)| *at == src_hunks)
.map(|(_, l)| (out_hunks, l.clone()))
.collect();
ends_on_the_files_last_line |= !trailer.is_empty();
files.push(FileDiff {
headers: src.headers.clone(),
trailer,
old_path: src.old_path.clone(),
new_path: src.new_path.clone(),
content,
});
}
let ends_on_the_inputs_last_line = patch.no_trailing_newline
&& last_fi == patch.files.len().checked_sub(1)
&& ends_on_the_files_last_line;
Ok(Patch {
preamble: patch.preamble.clone(),
files,
no_trailing_newline: ends_on_the_inputs_last_line,
})
}
fn resolve_id(
patch: &Patch,
id: &str,
subs_cache: &mut BTreeMap<usize, Vec<Hunk>>,
ids: &IdIndex,
chosen: &mut BTreeMap<usize, Vec<Chosen>>,
) -> Result<(), SelectError> {
let target = u64::from_str_radix(id, 16).map_err(|_| SelectError::UnknownId(id.to_string()))?;
let matched: Vec<(usize, usize)> = ids.lookup(target);
if matched.is_empty() {
return Err(SelectError::UnknownId(id.to_string()));
}
let refs: Vec<(&FileDiff, &Hunk)> = matched
.iter()
.map(|&(fi, si)| (&patch.files[fi], &subs_cache[&fi][si - 1]))
.collect();
if !all_same_content(&refs) {
return Err(SelectError::IdCollision(id.to_string()));
}
for (fi, si) in matched {
chosen.entry(fi).or_default().push(Chosen::Whole(si));
}
Ok(())
}
struct IdIndex {
by_id: HashMap<u64, Vec<(usize, usize)>>,
}
impl IdIndex {
fn new(patch: &Patch, subs_cache: &mut BTreeMap<usize, Vec<Hunk>>) -> Self {
let mut by_id: HashMap<u64, Vec<(usize, usize)>> = HashMap::new();
for (fi, f) in patch.files.iter().enumerate() {
if matches!(f.content, FileContent::Binary(_)) {
continue;
}
let subs = subs_cache.entry(fi).or_insert_with(|| build_file_subs(f));
for (si, sub) in subs.iter().enumerate() {
by_id
.entry(subhunk_hash(f, sub))
.or_default()
.push((fi, si + 1));
}
}
Self { by_id }
}
fn lookup(&self, target: u64) -> Vec<(usize, usize)> {
self.by_id.get(&target).cloned().unwrap_or_default()
}
}
#[derive(Clone, Copy)]
enum PathOwner {
One(usize),
Many,
}
pub(crate) struct PathIndex<'a> {
by_path: HashMap<&'a [u8], PathOwner>,
file_count: usize,
}
impl<'a> PathIndex<'a> {
pub(crate) fn new(patch: &'a Patch) -> Self {
let mut by_path: HashMap<&'a [u8], PathOwner> = HashMap::with_capacity(patch.files.len());
for (fi, f) in patch.files.iter().enumerate() {
for path in [f.new_path.as_deref(), f.old_path.as_deref()]
.into_iter()
.flatten()
{
by_path
.entry(path)
.and_modify(|owner| {
if !matches!(owner, PathOwner::One(seen) if *seen == fi) {
*owner = PathOwner::Many;
}
})
.or_insert(PathOwner::One(fi));
}
}
PathIndex {
by_path,
file_count: patch.files.len(),
}
}
pub(crate) fn resolve(&self, path: Option<&[u8]>) -> Result<usize, SelectError> {
let Some(p) = path else {
return if self.file_count == 1 {
Ok(0)
} else {
Err(SelectError::AmbiguousPath(
"<no path on multi-file diff>".into(),
))
};
};
match self.by_path.get(p) {
Some(PathOwner::One(fi)) => Ok(*fi),
Some(PathOwner::Many) => Err(SelectError::AmbiguousPath(
String::from_utf8_lossy(p).into_owned(),
)),
None => Err(SelectError::UnknownPath(
String::from_utf8_lossy(p).into_owned(),
)),
}
}
}
pub fn resolve_hunk(patch: &Patch, addr: &OsStr) -> Result<(usize, usize), SelectError> {
let bytes = os_bytes(addr);
let shown = String::from_utf8_lossy(bytes).into_owned();
let index_of = |b: &[u8]| std::str::from_utf8(b).ok().and_then(|s| s.parse().ok());
let (path, n): (Option<&[u8]>, Option<usize>) = match bytes.iter().rposition(|&b| b == b':') {
Some(i) if i > 0 && index_of(&bytes[i + 1..]).is_some() => {
(Some(&bytes[..i]), index_of(&bytes[i + 1..]))
}
_ => (None, index_of(bytes)),
};
let n = n.filter(|&n| n > 0).ok_or_else(|| {
SelectError::BadSelector(shown.clone())
})?;
let fi = PathIndex::new(patch).resolve(path)?;
match &patch.files[fi].content {
FileContent::Text(h) if n <= h.len() => Ok((fi, n - 1)),
FileContent::Text(_) => Err(SelectError::NoIndex(shown)),
FileContent::Binary(_) => Err(SelectError::BadSelector(format!("{shown} (binary file)"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::emit::emit;
use crate::gittest::applies_to_file;
use crate::parser::parse;
use crate::subhunk_id::subhunk_id;
const TWO_CHANGES: &str = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,5 +1,5 @@
a
-b
+B
c
-d
+D
e
";
fn mk_file(path: &str) -> FileDiff {
FileDiff {
headers: Vec::new(),
trailer: Vec::new(),
old_path: Some(path.as_bytes().to_vec()),
new_path: Some(path.as_bytes().to_vec()),
content: FileContent::Text(Vec::new()),
}
}
fn mk_hunk(lines: &[(LineKind, &str)]) -> Hunk {
Hunk {
old_start: 0,
old_lines: 0,
new_start: 0,
new_lines: 0,
section: Vec::new(),
lines: lines
.iter()
.map(|&(kind, text)| Line {
kind,
text: text.as_bytes().to_vec(),
no_newline: None,
})
.collect(),
}
}
#[test]
fn parse_selector_bare_index_list() {
let sels = parse_selectors(&["1,2".to_string()]).unwrap();
assert_eq!(sels.len(), 1);
assert_eq!(
sels[0],
Selector::File {
path: None,
indices: IndexSet::List(vec![1, 2]),
}
);
}
#[test]
fn huge_range_is_rejected_without_allocating() {
assert!(parse_index_list("1-100000000").is_err());
assert!(parse_selectors(&["1-100000000".to_string()]).is_err());
}
#[test]
fn parse_bare_star_is_all() {
let sels = parse_selectors(&["*".to_string()]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: None,
indices: IndexSet::All,
}
);
}
#[test]
fn parse_path_star_is_all() {
let sels = parse_selectors(&["src/f:*".to_string()]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: Some(b"src/f".to_vec()),
indices: IndexSet::All,
}
);
}
#[test]
fn parse_id_selector() {
let sels = parse_selectors(&["@a1b2c3d4e5f60718".to_string()]).unwrap();
assert_eq!(sels[0], Selector::Id("a1b2c3d4e5f60718".to_string()));
}
#[test]
fn parse_at_prefixed_path_is_path_form_not_id() {
let sels = parse_selectors(&["@foo:1".to_string()]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: Some(b"@foo".to_vec()),
indices: IndexSet::List(vec![1]),
}
);
}
#[test]
fn parse_bare_at_is_error() {
assert!(parse_selectors(&["@".to_string()]).is_err());
}
#[test]
fn parse_selector_path_with_range() {
let sels = parse_selectors(&["src/f:2-4".to_string()]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: Some(b"src/f".to_vec()),
indices: IndexSet::List(vec![2, 3, 4]),
}
);
}
#[test]
fn select_first_subhunk_only() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
let sels = parse_selectors(&["1".to_string()]).unwrap();
let out = select(&p, &sels).unwrap();
let text = String::from_utf8(emit(&out)).unwrap();
assert!(text.contains("+B"));
assert!(!text.contains("+D")); }
const TWO_FILES: &str = "\
diff --git a/x b/x
--- a/x
+++ b/x
@@ -1,3 +1,3 @@
a
-b
+B
c
diff --git a/y b/y
--- a/y
+++ b/y
@@ -1,3 +1,3 @@
p
-q
+Q
r
";
#[test]
fn select_across_two_files() {
let p = parse(TWO_FILES.as_bytes()).unwrap();
let sels = parse_selectors(&["x:1".to_string(), "y:1".to_string()]).unwrap();
let out = select(&p, &sels).unwrap();
assert_eq!(out.files.len(), 2);
let text = String::from_utf8(emit(&out)).unwrap();
assert!(text.contains("+B"));
assert!(text.contains("+Q"));
}
const SAME_TWICE: &str = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-x
+y
b
@@ -10,3 +10,3 @@
a
-x
+y
b
";
#[test]
fn select_bare_star_selects_every_subhunk() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
let sels = parse_selectors(&["*".to_string()]).unwrap();
let out = select(&p, &sels).unwrap();
let text = String::from_utf8(emit(&out)).unwrap();
assert!(text.contains("+B"), "first change present: {text}");
assert!(text.contains("+D"), "second change present: {text}");
}
#[test]
fn select_path_star_selects_named_file_only() {
let p = parse(TWO_FILES.as_bytes()).unwrap();
let sels = parse_selectors(&["x:*".to_string()]).unwrap();
let out = select(&p, &sels).unwrap();
assert_eq!(out.files.len(), 1);
let text = String::from_utf8(emit(&out)).unwrap();
assert!(text.contains("+B"));
assert!(!text.contains("+Q"), "file y must be excluded: {text}");
}
#[test]
fn select_by_id_picks_matching_subhunk() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
let view = build_view(&p);
let subs = &view[0];
let id = subhunk_id(&p.files[0], &subs[1]);
let sels = parse_selectors(&[format!("@{id}")]).unwrap();
let out = select(&p, &sels).unwrap();
let text = String::from_utf8(emit(&out)).unwrap();
assert!(text.contains("+D"), "addressed change present: {text}");
assert!(!text.contains("+B"), "other change excluded: {text}");
}
#[test]
fn many_ids_resolve_without_quadratic_blowup() {
const RUNS: usize = 8_000;
let mut diff = format!(
"diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1,{n} +1,{n} @@\n",
n = RUNS * 2
);
for i in 0..RUNS {
diff.push_str(&format!(" ctx{i}\n-old{i}\n+new{i}\n"));
}
let p = parse(diff.as_bytes()).unwrap();
let ids: Vec<String> = build_view(&p)[0]
.iter()
.map(|sub| format!("@{}", subhunk_id(&p.files[0], sub)))
.collect();
assert_eq!(ids.len(), RUNS, "one sub-hunk per change run");
let sels = parse_selectors(&ids).unwrap();
let started = std::time::Instant::now();
let out = select(&p, &sels).unwrap();
let elapsed = started.elapsed();
assert_eq!(out.files[0].hunk_count(), RUNS, "every id is selected");
assert!(
elapsed < std::time::Duration::from_secs(60),
"resolving {RUNS} ids took {elapsed:?}"
);
}
#[test]
fn select_id_is_case_insensitive() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
let view = build_view(&p);
let id = subhunk_id(&p.files[0], &view[0][0]).to_uppercase();
let sels = parse_selectors(&[format!("@{id}")]).unwrap();
assert!(select(&p, &sels).is_ok(), "uppercase id must still match");
}
#[test]
fn select_id_selects_all_identical_subhunks() {
let p = parse(SAME_TWICE.as_bytes()).unwrap();
let view = build_view(&p);
let subs = &view[0];
assert_eq!(subs.len(), 2);
let id0 = subhunk_id(&p.files[0], &subs[0]);
let id1 = subhunk_id(&p.files[0], &subs[1]);
assert_eq!(id0, id1, "identical changes must share an id");
let sels = parse_selectors(&[format!("@{id0}")]).unwrap();
let out = select(&p, &sels).unwrap();
match &out.files[0].content {
FileContent::Text(hunks) => {
assert_eq!(hunks.len(), 2, "both identical sub-hunks must be selected")
}
_ => panic!("expected text content"),
}
}
#[test]
fn select_unknown_id_errors() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
let sels = parse_selectors(&["@0000000000000000".to_string()]).unwrap();
assert!(matches!(select(&p, &sels), Err(SelectError::UnknownId(_))));
}
#[test]
fn collision_check_distinguishes_distinct_content() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
let view = build_view(&p);
let subs = &view[0];
let f = &p.files[0];
assert!(
all_same_content(&[(f, &subs[0]), (f, &subs[0])]),
"identical sub-hunks are not a collision"
);
assert!(
!all_same_content(&[(f, &subs[0]), (f, &subs[1])]),
"distinct sub-hunks sharing an id is a collision"
);
}
#[test]
fn collision_check_ignores_context_differences() {
let f = mk_file("src/a.rs");
let a = mk_hunk(&[
(LineKind::Context, "before-a"),
(LineKind::Del, "x"),
(LineKind::Add, "y"),
(LineKind::Context, "after-a"),
]);
let b = mk_hunk(&[
(LineKind::Context, "totally-different"),
(LineKind::Del, "x"),
(LineKind::Add, "y"),
]);
assert!(
all_same_content(&[(&f, &a), (&f, &b)]),
"identical changes in different context must not count as a collision"
);
}
#[test]
fn collision_check_flags_different_changed_lines() {
let f = mk_file("src/a.rs");
let a = mk_hunk(&[(LineKind::Context, "ctx"), (LineKind::Add, "y")]);
let b = mk_hunk(&[(LineKind::Context, "ctx"), (LineKind::Add, "z")]);
assert!(
!all_same_content(&[(&f, &a), (&f, &b)]),
"distinct changed lines must count as a collision"
);
}
#[test]
fn select_unknown_index_errors() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
let sels = parse_selectors(&["9".to_string()]).unwrap();
assert!(matches!(select(&p, &sels), Err(SelectError::NoIndex(_))));
}
#[test]
fn select_empty_is_error() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
assert_eq!(select(&p, &[]), Err(SelectError::EmptySelection));
}
#[test]
fn resolve_hunk_addresses_original_hunk() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
assert_eq!(resolve_hunk(&p, OsStr::new("1")).unwrap(), (0, 0));
}
#[test]
#[cfg(unix)]
fn selector_path_may_hold_invalid_utf8() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let sels = parse_selectors(&[OsString::from_vec(b"bad\xffname.txt:1,3".to_vec())]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: Some(b"bad\xffname.txt".to_vec()),
indices: IndexSet::List(vec![1, 3]),
}
);
match parse_selectors(&[OsString::from_vec(b"bad\xffname.txt".to_vec())]) {
Err(SelectError::BadSelector(msg)) => {
assert!(msg.contains("not valid UTF-8"), "message was {msg:?}")
}
other => panic!("expected BadSelector, got {other:?}"),
}
}
#[test]
fn removed_range_form_reports_friendly_error() {
for sel in ["1@1-3", "1@91-", "1@-90", "2@5", "src/f:1@1-90"] {
match parse_selectors(&[sel.to_string()]) {
Err(SelectError::RemovedRangeForm(s)) => {
assert_eq!(s, sel);
assert!(
format!("{}", SelectError::RemovedRangeForm(s)).contains("@L"),
"message for {sel} must steer to @L"
);
}
other => panic!("selector {sel}: expected RemovedRangeForm, got {other:?}"),
}
}
}
#[test]
fn bad_selector_reports_specific_reason() {
let cases = [
("2-1", "reversed range"),
("0", "1-based"),
("a", "not a number"),
];
for (sel, needle) in cases {
match parse_selectors(&[sel.to_string()]) {
Err(SelectError::BadSelector(msg)) => assert!(
msg.contains(needle),
"selector {sel}: message {msg:?} lacks {needle:?}"
),
other => panic!("selector {sel}: expected BadSelector, got {other:?}"),
}
}
}
#[test]
fn path_form_reports_the_set_reason_not_the_whole_arg() {
let cases = [
("f:2-1", "reversed range"),
("f:0", "1-based"),
("f:1-99999999", "range too large"),
];
for (sel, needle) in cases {
match parse_selectors(&[sel.to_string()]) {
Err(SelectError::BadSelector(msg)) => assert!(
msg.contains(needle),
"selector {sel}: message {msg:?} lacks {needle:?}"
),
other => panic!("selector {sel}: expected BadSelector, got {other:?}"),
}
}
let parsed = parse_selectors(&["x:y:1".to_string()]).unwrap();
assert!(
matches!(&parsed[0], Selector::File { path: Some(p), .. } if p == b"x:y"),
"expected path form for x:y:1, got {parsed:?}"
);
}
const PURE_ADD_FILE: &str = "\
diff --git a/f b/f
new file mode 100644
--- /dev/null
+++ b/f
@@ -0,0 +1,4 @@
+l1
+l2
+l3
+l4
";
#[test]
fn select_line_set_first_two_changed_lines() {
let p = parse(PURE_ADD_FILE.as_bytes()).unwrap();
let sels = parse_selectors(&["1@L1,2".to_string()]).unwrap();
let out = select(&p, &sels).unwrap();
let text = String::from_utf8(emit(&out)).unwrap();
assert!(text.contains("+l1"));
assert!(text.contains("+l2"));
assert!(!text.contains("+l3"));
assert!(!text.contains("+l4"));
}
#[test]
fn select_second_subhunk_applies_via_git() {
let p = parse(TWO_CHANGES.as_bytes()).unwrap();
let sels = parse_selectors(&["2".to_string()]).unwrap();
let out = select(&p, &sels).unwrap();
let diff = emit(&out);
assert!(
applies_to_file(&diff, "a\nb\nc\nd\ne\n"),
"second-only sub-hunk failed to apply:\n{}",
String::from_utf8_lossy(&diff)
);
}
const REPLACEMENT: &str = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,2 +1,2 @@
-a
-b
+A
+B
";
#[test]
fn parse_line_set_selector() {
let sels = parse_selectors(&["1@L1,3".to_string()]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: None,
indices: IndexSet::LineSet {
index: 1,
lines: vec![1, 3],
},
}
);
let sels = parse_selectors(&["2@L1-2,4".to_string()]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: None,
indices: IndexSet::LineSet {
index: 2,
lines: vec![1, 2, 4],
},
}
);
}
#[test]
fn parse_line_set_with_path() {
let sels = parse_selectors(&["src/f:2@L1".to_string()]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: Some(b"src/f".to_vec()),
indices: IndexSet::LineSet {
index: 2,
lines: vec![1],
},
}
);
let sels = parse_selectors(&["src/f:2@L1-2,4".to_string()]).unwrap();
assert_eq!(
sels[0],
Selector::File {
path: Some(b"src/f".to_vec()),
indices: IndexSet::LineSet {
index: 2,
lines: vec![1, 2, 4],
},
}
);
}
#[test]
fn parse_line_set_rejects_malformed() {
assert!(parse_selectors(&["1@L".to_string()]).is_err());
assert!(parse_selectors(&["1@L0".to_string()]).is_err());
assert!(parse_selectors(&["0@L1".to_string()]).is_err());
assert!(parse_selectors(&["1@L3-1".to_string()]).is_err());
assert!(parse_selectors(&["@deadbeef@L1-2".to_string()]).is_err());
}
#[test]
fn select_line_set_separates_deletions_from_additions() {
let p = parse(REPLACEMENT.as_bytes()).unwrap();
let dels = select(&p, &parse_selectors(&["1@L1,2".to_string()]).unwrap()).unwrap();
let dels_text = String::from_utf8(emit(&dels)).unwrap();
assert!(dels_text.contains("-a") && dels_text.contains("-b"));
assert!(!dels_text.contains("+A") && !dels_text.contains("+B"));
assert!(
applies_to_file(&emit(&dels), "a\nb\n"),
"deletion piece must apply"
);
let adds = select(&p, &parse_selectors(&["1@L3,4".to_string()]).unwrap()).unwrap();
let adds_text = String::from_utf8(emit(&adds)).unwrap();
assert!(adds_text.contains("+A") && adds_text.contains("+B"));
assert!(
!adds_text.contains("-a"),
"deletions must be context, not `-`"
);
assert!(
applies_to_file(&emit(&adds), "a\nb\n"),
"addition piece must apply"
);
}
#[test]
fn select_line_set_out_of_range_errors() {
let p = parse(REPLACEMENT.as_bytes()).unwrap(); let sels = parse_selectors(&["1@L5".to_string()]).unwrap();
assert!(matches!(select(&p, &sels), Err(SelectError::LineSelect(_))));
}
#[test]
fn select_line_set_unknown_index_errors() {
let p = parse(REPLACEMENT.as_bytes()).unwrap();
let sels = parse_selectors(&["9@L1".to_string()]).unwrap();
assert!(matches!(select(&p, &sels), Err(SelectError::NoIndex(_))));
}
#[test]
fn select_line_set_combined_with_whole_same_subhunk_rejected() {
let p = parse(REPLACEMENT.as_bytes()).unwrap();
let sels = parse_selectors(&["1".to_string(), "1@L1".to_string()]).unwrap();
assert!(matches!(select(&p, &sels), Err(SelectError::LineSelect(_))));
}
#[test]
fn select_two_line_sets_of_same_subhunk_rejected() {
let p = parse(REPLACEMENT.as_bytes()).unwrap();
let sels = parse_selectors(&["1@L1,2".to_string(), "1@L3,4".to_string()]).unwrap();
assert!(matches!(select(&p, &sels), Err(SelectError::LineSelect(_))));
}
}