Skip to content

Commit b43cc8a

Browse files
authored
fix: make compare_lines a valid total order (#105) (#106)
* fix: make compare_lines a valid total order (#105) The previous comparator could return Ordering::Less for both compare_lines(a, b) and compare_lines(b, a) when two lines shared a prefix ending just before "**". That violated antisymmetry, so Vec::sort_by produced different orderings on different platforms, breaking "CODEOWNERS out of date" validation. Compare path components left-to-right with "**" treated as a sentinel that sorts before any non-"**" component, falling back to full-line lexical compare as a deterministic tiebreaker. The shared comparator is reused by codeowners_file_parser, so both generation and parse agree. * test: prove transitivity and cover edge branches in compare_lines - Rename the antisymmetry sweep so the name matches what it asserts. - Add a transitivity sweep over the same special-character set; this is the property that was actually missing from coverage of the Ord contract. - Add a length-asymmetry case (shorter path vs longer extension) and an equal-path tiebreaker case, exercising the (None, Some) / (Some, None) and full-line fallback arms.
1 parent 49629b1 commit b43cc8a

1 file changed

Lines changed: 119 additions & 9 deletions

File tree

src/ownership/file_generator.rs

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,37 @@ impl FileGenerator {
4949
}
5050

5151
pub fn compare_lines(a: &String, b: &String) -> Ordering {
52-
if let Some((prefix, _)) = a.split_once("**")
53-
&& b.starts_with(prefix)
54-
{
55-
return Ordering::Less;
52+
let path_a = extract_path(a);
53+
let path_b = extract_path(b);
54+
55+
let mut comps_a = path_a.split('/');
56+
let mut comps_b = path_b.split('/');
57+
58+
loop {
59+
match (comps_a.next(), comps_b.next()) {
60+
(None, None) => return a.cmp(b),
61+
(None, Some(_)) => return Ordering::Less,
62+
(Some(_), None) => return Ordering::Greater,
63+
(Some(ca), Some(cb)) => match compare_component(ca, cb) {
64+
Ordering::Equal => continue,
65+
ord => return ord,
66+
},
67+
}
5668
}
57-
if let Some((prefix, _)) = b.split_once("**")
58-
&& a.starts_with(prefix)
59-
{
60-
return Ordering::Greater;
69+
}
70+
71+
fn extract_path(line: &str) -> &str {
72+
let stripped = line.strip_prefix("# ").unwrap_or(line);
73+
stripped.split_once(' ').map(|(p, _)| p).unwrap_or(stripped)
74+
}
75+
76+
fn compare_component(a: &str, b: &str) -> Ordering {
77+
match (a == "**", b == "**") {
78+
(true, true) => Ordering::Equal,
79+
(true, false) => Ordering::Less,
80+
(false, true) => Ordering::Greater,
81+
(false, false) => a.cmp(b),
6182
}
62-
a.cmp(b)
6383
}
6484

6585
#[cfg(test)]
@@ -181,6 +201,96 @@ mod tests {
181201
assert_eq!(sorted, vec!["/directory/owner1/** @foo", "/directory/owner2/** @bar"]);
182202
}
183203

204+
#[test]
205+
fn test_compare_lines_is_antisymmetric_for_shared_double_star_prefix() {
206+
let a = "/foo/**/*bar* @org/example-team".to_string();
207+
let b = "/foo/**/baz/**/* @org/example-team".to_string();
208+
209+
assert_eq!(compare_lines(&a, &b), Ordering::Less);
210+
assert_eq!(compare_lines(&b, &a), Ordering::Greater);
211+
212+
let mut forward = vec![a.clone(), b.clone()];
213+
let mut reverse = vec![b.clone(), a.clone()];
214+
forward.sort_by(compare_lines);
215+
reverse.sort_by(compare_lines);
216+
assert_eq!(forward, reverse);
217+
assert_eq!(forward, vec![a, b]);
218+
}
219+
220+
fn special_character_set() -> Vec<String> {
221+
vec![
222+
"/directory/** @bop".to_string(),
223+
"/directory/owner/** @bar".to_string(),
224+
"/directory/owner/(my_folder)/**/** @foo".to_string(),
225+
"/directory/owner/(my_folder)/without_glob @zoo".to_string(),
226+
"/directory/owner/my_folder/** @baz".to_string(),
227+
]
228+
}
229+
230+
#[test]
231+
fn test_compare_lines_is_antisymmetric_across_special_character_set() {
232+
let lines = special_character_set();
233+
for x in &lines {
234+
assert_eq!(compare_lines(x, x), Ordering::Equal);
235+
for y in &lines {
236+
if x == y {
237+
continue;
238+
}
239+
let xy = compare_lines(x, y);
240+
let yx = compare_lines(y, x);
241+
assert_eq!(xy.reverse(), yx, "asymmetric for {x:?} vs {y:?}");
242+
}
243+
}
244+
}
245+
246+
#[test]
247+
fn test_compare_lines_is_transitive_across_special_character_set() {
248+
let lines = special_character_set();
249+
for x in &lines {
250+
for y in &lines {
251+
for z in &lines {
252+
let xy = compare_lines(x, y);
253+
let yz = compare_lines(y, z);
254+
let xz = compare_lines(x, z);
255+
if xy != Ordering::Greater && yz != Ordering::Greater {
256+
assert_ne!(xz, Ordering::Greater, "transitivity broken: {x:?} <= {y:?} <= {z:?} but x > z");
257+
}
258+
if xy != Ordering::Less && yz != Ordering::Less {
259+
assert_ne!(xz, Ordering::Less, "transitivity broken: {x:?} >= {y:?} >= {z:?} but x < z");
260+
}
261+
}
262+
}
263+
}
264+
}
265+
266+
#[test]
267+
fn test_compare_lines_orders_shorter_path_before_longer_extension() {
268+
let shorter = "/foo @a".to_string();
269+
let longer = "/foo/bar @b".to_string();
270+
271+
assert_eq!(compare_lines(&shorter, &longer), Ordering::Less);
272+
assert_eq!(compare_lines(&longer, &shorter), Ordering::Greater);
273+
}
274+
275+
#[test]
276+
fn test_compare_lines_falls_back_to_full_line_when_paths_equal() {
277+
let a = "/foo/** @a".to_string();
278+
let b = "/foo/** @b".to_string();
279+
280+
assert_eq!(compare_lines(&a, &b), Ordering::Less);
281+
assert_eq!(compare_lines(&b, &a), Ordering::Greater);
282+
}
283+
284+
#[test]
285+
fn test_compare_lines_ignores_disabled_comment_prefix() {
286+
let enabled = "/foo/owner/** @bar".to_string();
287+
let disabled = "# /foo/owner/** @bar".to_string();
288+
let other = "/foo/owner/(extra)/file @baz".to_string();
289+
290+
assert_eq!(compare_lines(&enabled, &other), compare_lines(&disabled, &other));
291+
assert_eq!(compare_lines(&other, &enabled), compare_lines(&other, &disabled));
292+
}
293+
184294
#[test]
185295
fn test_sorting_with_special_characters() {
186296
let entries = vec![

0 commit comments

Comments
 (0)