majordomus_cli/order.rs
1//! Canonical presentation order.
2//!
3//! One collection has one order, and every interface that shows it shows that order. A
4//! sort written beside a renderer is a second opinion about the same collection, and two
5//! opinions drift: the command line alphabetises, the Cockpit groups, the generated index
6//! keeps whatever the walk produced, and a reader who compares them concludes that the
7//! surfaces disagree about the repository rather than about sorting.
8//!
9//! The order is a total order over four parts, most significant first:
10//!
11//! 1. **group** — the semantic bucket the item is filed under, itself compared naturally;
12//! an item with no group sorts after every grouped item, because an ungrouped tail is
13//! legible and an ungrouped head hides the groups.
14//! 2. **rank** — an explicit position inside the group, ascending, for the collections
15//! whose domain genuinely has one (a lifecycle runs setup before conclude, whatever
16//! those words sort as). It defaults to [`OrderKey::UNRANKED`], so a collection that
17//! has no such semantics never mentions it.
18//! 3. **label** — what a person reads, compared naturally: digit runs by value, so
19//! `item-2` precedes `item-10`, and letters case-insensitively, so `Alpha` and `alpha`
20//! stay adjacent.
21//! 4. **identity** — the canonical id, compared naturally, as the final tie-breaker.
22//!
23//! The fourth part is what makes the order total rather than merely tidy. Two items with
24//! the same label must not exchange places because an unrelated item was added, because a
25//! different iterator was used, or because one machine's filesystem enumerates differently
26//! from another's; identities are unique, so the comparison always ends there.
27//!
28//! A numeric sample is ordered here too, by [`numeric`]: nobody reads it item by item, but a
29//! median read off it depends on its order, and a float has exactly one order worth having.
30//!
31//! ```
32//! use majordomus_cli::order::{canonical, OrderKey, Ordered};
33//!
34//! struct Recipe {
35//! group: &'static str,
36//! name: &'static str,
37//! }
38//!
39//! impl Ordered for Recipe {
40//! fn order_key(&self) -> OrderKey<'_> {
41//! OrderKey::grouped(self.group, self.name, self.name)
42//! }
43//! }
44//!
45//! let mut recipes = vec![
46//! Recipe { group: "quality", name: "test-10" },
47//! Recipe { group: "development", name: "serve" },
48//! Recipe { group: "quality", name: "test-2" },
49//! ];
50//! canonical(&mut recipes);
51//! let order: Vec<_> = recipes.iter().map(|r| r.name).collect();
52//! assert_eq!(order, ["serve", "test-2", "test-10"]);
53//! ```
54
55use std::cmp::Ordering;
56
57/// What a collection's items answer so that the collection can be put in canonical order.
58///
59/// Implemented on the domain type, once, next to the type — not on a view of it beside a
60/// renderer. Every projection then sorts by calling [`canonical`], and none of them holds
61/// an opinion of its own about the sequence.
62pub trait Ordered {
63 /// The four parts this item is ordered by. Borrowed from the item: building a key
64 /// allocates nothing, so sorting a large collection costs the comparisons and no more.
65 fn order_key(&self) -> OrderKey<'_>;
66}
67
68/// A reference orders as the item it points at, so a projection can put a borrowed view of
69/// a collection in canonical order without cloning every item into a vector first.
70impl<T: Ordered + ?Sized> Ordered for &T {
71 fn order_key(&self) -> OrderKey<'_> {
72 (**self).order_key()
73 }
74}
75
76/// The four parts of the canonical order, most significant first. See the module
77/// documentation for what each part means and why the last one is not optional.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct OrderKey<'a> {
80 /// The semantic bucket, or `None` for an item that is not filed under one. `None`
81 /// sorts after every named group.
82 pub group: Option<&'a str>,
83 /// The explicit position inside the group, ascending. [`OrderKey::UNRANKED`] when the
84 /// collection has no such semantics, which is most of them.
85 pub rank: i64,
86 /// What a person reads.
87 pub label: &'a str,
88 /// The canonical id: unique, and therefore the tie-breaker that makes the order total.
89 pub identity: &'a str,
90}
91
92impl<'a> OrderKey<'a> {
93 /// The rank of an item whose collection has no explicit ordering semantics. Zero, so
94 /// that a collection where only some items are ranked puts those first, which is what
95 /// an explicit rank is for.
96 pub const UNRANKED: i64 = 0;
97
98 /// An item with no group and no explicit rank: ordered by its label, then its identity.
99 pub fn plain(label: &'a str, identity: &'a str) -> Self {
100 Self {
101 group: None,
102 rank: Self::UNRANKED,
103 label,
104 identity,
105 }
106 }
107
108 /// An item filed under a group, ordered inside it by its label, then its identity.
109 pub fn grouped(group: &'a str, label: &'a str, identity: &'a str) -> Self {
110 Self {
111 group: Some(group),
112 rank: Self::UNRANKED,
113 label,
114 identity,
115 }
116 }
117
118 /// The same key with an explicit position inside its group.
119 pub fn ranked(self, rank: i64) -> Self {
120 Self { rank, ..self }
121 }
122}
123
124impl Ord for OrderKey<'_> {
125 fn cmp(&self, other: &Self) -> Ordering {
126 group_cmp(self.group, other.group)
127 .then_with(|| self.rank.cmp(&other.rank))
128 .then_with(|| natural_cmp(self.label, other.label))
129 .then_with(|| natural_cmp(self.identity, other.identity))
130 }
131}
132
133impl PartialOrd for OrderKey<'_> {
134 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
135 Some(self.cmp(other))
136 }
137}
138
139/// Put a collection in canonical order, in place.
140///
141/// The sort is stable, but nothing relies on that: the identity part of the key makes the
142/// order total, so the result does not depend on the order the items arrived in. That is
143/// the property [`is_canonical`] checks and the permutation tests below prove.
144pub fn canonical<T: Ordered>(items: &mut [T]) {
145 items.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
146}
147
148/// Put a collection of plain strings in canonical order, in place.
149///
150/// A `Vec<String>` has no group, no rank and no identity apart from the string itself, so it
151/// cannot implement [`Ordered`] without a newtype nobody wants. It still has an order, and
152/// before this existed every caller picked one of two: `.sort()`, which is `Ord for str` and
153/// puts `case-107` before `case-9`, or a hand-written `sort_by(|a, b| natural_cmp(a, b))`,
154/// which is this function spelled out again at the call site. The second is the duplication
155/// `project.canonical-order` exists to prevent, and the first is a different order from the
156/// one every other collection in the crate is shown in.
157///
158/// ```
159/// use majordomus_cli::order::canonical_strings;
160///
161/// let mut cases = vec!["case-107".to_string(), "case-9".to_string(), "case-10".to_string()];
162/// canonical_strings(&mut cases);
163/// assert_eq!(cases, ["case-9", "case-10", "case-107"]);
164/// ```
165pub fn canonical_strings<S: AsRef<str>>(items: &mut [S]) {
166 items.sort_by(|a, b| natural_cmp(a.as_ref(), b.as_ref()));
167}
168
169/// Put a numeric sample in ascending order, in place.
170///
171/// A sample is not a collection a person reads item by item: it is ordered so that its
172/// order statistics — a median, a quantile, the bounds of a bootstrap interval — can be read
173/// off it. It still has exactly one order, IEEE 754's total order, in which every value,
174/// both zeros and a NaN included, has one fixed place, so the result never depends on the
175/// order the values arrived in. It is written here, once, instead of as a comparator beside
176/// every piece of arithmetic that needs one.
177///
178/// ```
179/// use majordomus_cli::order::numeric;
180///
181/// let mut sample = vec![0.5, 0.0, 0.25, -0.0];
182/// numeric(&mut sample);
183/// assert_eq!(sample, [0.0, 0.0, 0.25, 0.5]);
184/// assert!(sample[0].is_sign_negative(), "-0 has its own place, before +0");
185/// ```
186pub fn numeric(values: &mut [f64]) {
187 values.sort_by(f64::total_cmp);
188}
189
190/// Put a set of numeric samples in ascending order, in place: value by value in
191/// [`numeric`]'s order, and a sample that is a prefix of another before it. Once each sample
192/// is itself in [`numeric`] order, this is what makes a computation that draws among the
193/// samples independent of the order they were listed in.
194///
195/// ```
196/// use majordomus_cli::order::numeric_samples;
197///
198/// let mut samples = vec![vec![0.3, 0.4], vec![0.1], vec![0.3]];
199/// numeric_samples(&mut samples);
200/// assert_eq!(samples, [vec![0.1], vec![0.3], vec![0.3, 0.4]]);
201/// ```
202pub fn numeric_samples(samples: &mut [Vec<f64>]) {
203 samples.sort_by(|a, b| {
204 a.iter()
205 .zip(b.iter())
206 .map(|(x, y)| x.total_cmp(y))
207 .find(|o| o.is_ne())
208 .unwrap_or_else(|| a.len().cmp(&b.len()))
209 });
210}
211
212/// Is this collection already in canonical order? What a validator asks of a projection it
213/// did not build, and what a test asks of an interface's output.
214pub fn is_canonical<T: Ordered>(items: &[T]) -> bool {
215 items
216 .windows(2)
217 .all(|pair| pair[0].order_key() <= pair[1].order_key())
218}
219
220/// A group compares naturally against another group; the absence of a group sorts last.
221fn group_cmp(a: Option<&str>, b: Option<&str>) -> Ordering {
222 match (a, b) {
223 (Some(a), Some(b)) => natural_cmp(a, b),
224 (Some(_), None) => Ordering::Less,
225 (None, Some(_)) => Ordering::Greater,
226 (None, None) => Ordering::Equal,
227 }
228}
229
230/// A bare string is ordered by itself.
231///
232/// A list of paths, ids or names is a collection like any other, and it had no way to reach
233/// the canonical order without a comparator written beside it. This is that way: one
234/// implementation, so `canonical` orders a `Vec<String>` the same way it orders everything
235/// else — naturally, and identically on every machine, which `sort()` does not promise
236/// because it compares bytes and `natural_cmp` compares what a person reads.
237///
238/// ```
239/// use majordomus_cli::order::canonical;
240///
241/// let mut paths = vec!["a/item-10".to_string(), "a/item-2".to_string()];
242/// canonical(&mut paths);
243/// assert_eq!(paths, ["a/item-2", "a/item-10"]);
244/// ```
245impl Ordered for String {
246 fn order_key(&self) -> OrderKey<'_> {
247 OrderKey::plain(self, self)
248 }
249}
250
251/// Compare two human-facing strings the way a person reads them.
252///
253/// A run of ASCII digits compares by value, so `item-2` precedes `item-10` instead of
254/// following it. Everything else compares by Unicode scalar, case-insensitively for ASCII
255/// letters first so that `Alpha` and `alpha` sort together, and case-sensitively after, so
256/// that two strings differing only in case still have a fixed order rather than comparing
257/// equal and letting the caller's input order decide.
258///
259/// Separators are not normalised. `a-b` and `a_b` are different strings and stay different:
260/// folding them together would make two distinct identities compare equal, which is exactly
261/// the tie this order exists to break.
262///
263/// ```
264/// use majordomus_cli::order::natural_cmp;
265/// use std::cmp::Ordering;
266///
267/// assert_eq!(natural_cmp("item-2", "item-10"), Ordering::Less);
268/// assert_eq!(natural_cmp("item-002", "item-2"), Ordering::Less);
269/// assert_eq!(natural_cmp("Alpha", "beta"), Ordering::Less);
270/// ```
271pub fn natural_cmp(a: &str, b: &str) -> Ordering {
272 let folded = fold_cmp(a, b);
273 if folded != Ordering::Equal {
274 return folded;
275 }
276 // Same word to a reader; still two different strings. Compare the raw scalars so that
277 // the pair has one fixed order on every machine.
278 exact_cmp(a, b)
279}
280
281/// The reader's comparison: digit runs by value, letters folded to lower case.
282fn fold_cmp(a: &str, b: &str) -> Ordering {
283 let (mut a, mut b) = (a.chars().peekable(), b.chars().peekable());
284 loop {
285 match (a.peek().copied(), b.peek().copied()) {
286 (None, None) => return Ordering::Equal,
287 (None, Some(_)) => return Ordering::Less,
288 (Some(_), None) => return Ordering::Greater,
289 (Some(x), Some(y)) => {
290 if x.is_ascii_digit() && y.is_ascii_digit() {
291 let ordering = digits_cmp(&mut a, &mut b);
292 if ordering != Ordering::Equal {
293 return ordering;
294 }
295 continue;
296 }
297 let ordering = fold(x).cmp(&fold(y));
298 if ordering != Ordering::Equal {
299 return ordering;
300 }
301 a.next();
302 b.next();
303 }
304 }
305 }
306}
307
308/// Compare the digit run at the head of each iterator by the number it spells, consuming
309/// both runs. A longer run of significant digits is the larger number; equal numbers are
310/// separated by how they were written, so that `002` and `2` have a fixed order.
311fn digits_cmp<I, J>(a: &mut std::iter::Peekable<I>, b: &mut std::iter::Peekable<J>) -> Ordering
312where
313 I: Iterator<Item = char>,
314 J: Iterator<Item = char>,
315{
316 let left = take_digits(a);
317 let right = take_digits(b);
318 let (lt, rt) = (left.trim_start_matches('0'), right.trim_start_matches('0'));
319 lt.len()
320 .cmp(&rt.len())
321 .then_with(|| lt.cmp(rt))
322 // Equal value, different spelling: the one written with more leading zeros first.
323 // Arbitrary, and fixed — which is the whole requirement.
324 .then_with(|| right.len().cmp(&left.len()))
325}
326
327fn take_digits<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) -> String {
328 let mut run = String::new();
329 while let Some(c) = chars.peek().copied() {
330 if !c.is_ascii_digit() {
331 break;
332 }
333 run.push(c);
334 chars.next();
335 }
336 run
337}
338
339/// Scalar-by-scalar comparison, used only to separate two strings a reader would call the
340/// same word.
341fn exact_cmp(a: &str, b: &str) -> Ordering {
342 a.cmp(b)
343}
344
345fn fold(c: char) -> char {
346 c.to_ascii_lowercase()
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 /// The keys of a fixture, in the order canonical() puts them in.
354 fn ordered(mut items: Vec<Item>) -> Vec<&'static str> {
355 canonical(&mut items);
356 items.iter().map(|i| i.identity).collect()
357 }
358
359 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
360 struct Item {
361 group: Option<&'static str>,
362 rank: i64,
363 label: &'static str,
364 identity: &'static str,
365 }
366
367 impl Item {
368 const fn new(
369 group: Option<&'static str>,
370 rank: i64,
371 label: &'static str,
372 identity: &'static str,
373 ) -> Self {
374 Self {
375 group,
376 rank,
377 label,
378 identity,
379 }
380 }
381 }
382
383 impl Ordered for Item {
384 fn order_key(&self) -> OrderKey<'_> {
385 OrderKey {
386 group: self.group,
387 rank: self.rank,
388 label: self.label,
389 identity: self.identity,
390 }
391 }
392 }
393
394 const FIXTURE: [Item; 7] = [
395 Item::new(Some("governance"), 0, "rules", "governance.rules"),
396 Item::new(Some("governance"), 0, "doctrines", "governance.doctrines"),
397 Item::new(Some("ai"), 0, "providers", "ai.providers"),
398 Item::new(None, 0, "orphan", "orphan"),
399 Item::new(Some("ai"), 0, "sessions", "ai.sessions"),
400 Item::new(Some("knowledge"), 0, "adr-10", "knowledge.adr-10"),
401 Item::new(Some("knowledge"), 0, "adr-2", "knowledge.adr-2"),
402 ];
403
404 #[test]
405 fn groups_come_first_then_labels_then_the_ungrouped_tail() {
406 assert_eq!(
407 ordered(FIXTURE.to_vec()),
408 [
409 "ai.providers",
410 "ai.sessions",
411 "governance.doctrines",
412 "governance.rules",
413 "knowledge.adr-2",
414 "knowledge.adr-10",
415 "orphan",
416 ]
417 );
418 }
419
420 #[test]
421 fn every_permutation_of_the_fixture_yields_the_same_order() {
422 let expected = ordered(FIXTURE.to_vec());
423 for permutation in permutations(&FIXTURE) {
424 assert_eq!(
425 ordered(permutation),
426 expected,
427 "a permutation ordered differently"
428 );
429 }
430 }
431
432 #[test]
433 fn an_explicit_rank_beats_the_label_inside_its_group() {
434 let lifecycle = vec![
435 Item::new(Some("task"), 3, "conclude", "task.conclude"),
436 Item::new(Some("task"), 1, "setup", "task.setup"),
437 Item::new(Some("task"), 2, "work", "task.work"),
438 ];
439 assert_eq!(
440 ordered(lifecycle),
441 ["task.setup", "task.work", "task.conclude"]
442 );
443 }
444
445 #[test]
446 fn identical_labels_are_separated_by_identity() {
447 let twins = vec![
448 Item::new(Some("g"), 0, "same", "g.second"),
449 Item::new(Some("g"), 0, "same", "g.first"),
450 ];
451 assert_eq!(ordered(twins), ["g.first", "g.second"]);
452 }
453
454 #[test]
455 fn digit_runs_compare_by_value() {
456 assert_eq!(natural_cmp("item-2", "item-10"), Ordering::Less);
457 assert_eq!(natural_cmp("item-10", "item-9"), Ordering::Greater);
458 assert_eq!(natural_cmp("v1.9.0", "v1.10.0"), Ordering::Less);
459 assert_eq!(natural_cmp("adr-0009", "adr-0010"), Ordering::Less);
460 }
461
462 #[test]
463 fn leading_zeros_do_not_change_the_value_but_do_fix_the_order() {
464 assert_eq!(natural_cmp("item-02", "item-2"), Ordering::Less);
465 assert_eq!(natural_cmp("item-2", "item-02"), Ordering::Greater);
466 assert_eq!(natural_cmp("item-2", "item-2"), Ordering::Equal);
467 }
468
469 #[test]
470 fn case_folds_first_and_separates_after() {
471 assert_eq!(natural_cmp("Alpha", "beta"), Ordering::Less);
472 assert_eq!(natural_cmp("alpha", "Beta"), Ordering::Less);
473 assert_ne!(natural_cmp("Alpha", "alpha"), Ordering::Equal);
474 assert_eq!(natural_cmp("alpha", "alpha"), Ordering::Equal);
475 }
476
477 #[test]
478 fn separators_are_not_normalised_away() {
479 assert_ne!(natural_cmp("a-b", "a_b"), Ordering::Equal);
480 assert_ne!(natural_cmp("a b", "a-b"), Ordering::Equal);
481 }
482
483 #[test]
484 fn a_prefix_precedes_the_string_that_extends_it() {
485 assert_eq!(natural_cmp("serve", "serve-http"), Ordering::Less);
486 assert_eq!(natural_cmp("", "a"), Ordering::Less);
487 assert_eq!(natural_cmp("", ""), Ordering::Equal);
488 }
489
490 #[test]
491 fn is_canonical_agrees_with_canonical() {
492 let mut items = FIXTURE.to_vec();
493 assert!(
494 !is_canonical(&items),
495 "the fixture is deliberately out of order"
496 );
497 canonical(&mut items);
498 assert!(is_canonical(&items));
499 }
500
501 #[test]
502 fn the_order_is_a_total_order_over_the_fixture() {
503 // Antisymmetry and transitivity over every pair and triple: a comparator that fails
504 // either can make sort() produce a different answer for a different input order.
505 for a in FIXTURE.iter() {
506 for b in FIXTURE.iter() {
507 let (ab, ba) = (
508 a.order_key().cmp(&b.order_key()),
509 b.order_key().cmp(&a.order_key()),
510 );
511 assert_eq!(ab, ba.reverse(), "not antisymmetric: {a:?} vs {b:?}");
512 assert_eq!(
513 ab == Ordering::Equal,
514 a == b,
515 "two distinct items compared equal"
516 );
517 for c in FIXTURE.iter() {
518 let bc = b.order_key().cmp(&c.order_key());
519 if ab == bc && ab != Ordering::Equal {
520 assert_eq!(a.order_key().cmp(&c.order_key()), ab, "not transitive");
521 }
522 }
523 }
524 }
525 }
526
527 /// Every permutation of a slice, smallest first. Seven items is 5040 orders, which is
528 /// cheap and exhaustive; a random shuffle would prove less and flake more.
529 fn permutations<T: Copy>(items: &[T]) -> Vec<Vec<T>> {
530 let mut out = Vec::new();
531 let mut current: Vec<T> = items.to_vec();
532 let n = current.len();
533 let mut counters = vec![0usize; n];
534 out.push(current.clone());
535 let mut i = 0;
536 while i < n {
537 if counters[i] < i {
538 let j = if i % 2 == 0 { 0 } else { counters[i] };
539 current.swap(j, i);
540 out.push(current.clone());
541 counters[i] += 1;
542 i = 0;
543 } else {
544 counters[i] = 0;
545 i += 1;
546 }
547 }
548 out
549 }
550}