Skip to main content

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}