Skip to main content

laterite_core/
arrange.rs

1//! Ordered hierarchies: one engine for every list things insert themselves
2//! into (the menu, the settings index, a dashboard, a plugin's own list).
3//!
4//! An entry carries a [`Position`]: a `slot` (a named band that says what
5//! kind of thing it is), an `order` inside the band, and an optional anchor
6//! to a sibling. A [`Hierarchy`] owns a list: its policy says whether a
7//! deployment and an operator may rearrange it, and which keys stay pinned at
8//! the ends. [`arrange`] sorts, anchors and applies the overrides.
9
10use serde::{Deserialize, Deserializer, Serialize};
11
12/// The bands a slot is chosen from, on a hundreds scale: an author names the
13/// one that matches what the thing is, and the lists read sensibly without
14/// anyone reading the whole menu. Anything a plugin contributes without a
15/// slot lands in [`slot::APPLICATION`].
16pub mod slot {
17    /// The home of the panel: the dashboard.
18    pub const HOME: i32 = 0;
19    /// What the site publishes: pages, posts, themes.
20    pub const CONTENT: i32 = 100;
21    /// Files: the media library.
22    pub const MEDIA: i32 = 200;
23    /// The application's own screens: the default for a plugin's resources.
24    pub const APPLICATION: i32 = 500;
25    /// Accounts and roles.
26    pub const PEOPLE: i32 = 700;
27    /// Reports, logs and tools an operator opens on occasion.
28    pub const TOOLS: i32 = 800;
29    /// The panel itself: settings.
30    pub const SYSTEM: i32 = 900;
31    /// After everything, pinned or not.
32    pub const LAST: i32 = i32::MAX;
33
34    /// The names a descriptor writes, as `slot: media`.
35    pub const NAMES: [(&str, i32); 8] = [
36        ("home", HOME),
37        ("content", CONTENT),
38        ("media", MEDIA),
39        ("application", APPLICATION),
40        ("people", PEOPLE),
41        ("tools", TOOLS),
42        ("system", SYSTEM),
43        ("last", LAST),
44    ];
45
46    /// The band a name stands for.
47    pub fn parse(name: &str) -> Option<i32> {
48        NAMES
49            .iter()
50            .find(|(n, _)| n.eq_ignore_ascii_case(name.trim()))
51            .map(|(_, v)| *v)
52    }
53
54    /// The name of a band, for the boot log and `lat doctor`.
55    pub fn name(value: i32) -> String {
56        NAMES
57            .iter()
58            .find(|(_, v)| *v == value)
59            .map(|(n, _)| n.to_string())
60            .unwrap_or_else(|| value.to_string())
61    }
62}
63
64/// Where an entry sits in its hierarchy.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(default, deny_unknown_fields)]
67#[non_exhaustive]
68pub struct Position {
69    /// The band, by number; a descriptor writes a name (`slot: media`).
70    #[serde(deserialize_with = "deserialize_slot")]
71    pub slot: i32,
72    /// Inside the band, lowest first; equal orders sort by label.
73    pub order: i32,
74    /// Sits right after the sibling with this key, whatever the bands say.
75    pub after: Option<String>,
76    /// Sits right before the sibling with this key.
77    pub before: Option<String>,
78}
79
80impl Default for Position {
81    fn default() -> Self {
82        Self {
83            slot: slot::APPLICATION,
84            order: 0,
85            after: None,
86            before: None,
87        }
88    }
89}
90
91impl Position {
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    pub fn slot(mut self, slot: i32) -> Self {
97        self.slot = slot;
98        self
99    }
100
101    pub fn order(mut self, order: i32) -> Self {
102        self.order = order;
103        self
104    }
105
106    pub fn after(mut self, key: impl Into<String>) -> Self {
107        self.after = Some(key.into());
108        self
109    }
110
111    pub fn before(mut self, key: impl Into<String>) -> Self {
112        self.before = Some(key.into());
113        self
114    }
115}
116
117/// A slot as a descriptor writes it: a band's name, or a number.
118pub fn deserialize_slot<'de, D>(deserializer: D) -> Result<i32, D::Error>
119where
120    D: Deserializer<'de>,
121{
122    #[derive(Deserialize)]
123    #[serde(untagged)]
124    enum Raw {
125        Number(i32),
126        Name(String),
127    }
128    match Raw::deserialize(deserializer)? {
129        Raw::Number(n) => Ok(n),
130        Raw::Name(name) => slot::parse(&name).ok_or_else(|| {
131            serde::de::Error::custom(format!(
132                "unknown slot {name:?}; one of {}",
133                slot::NAMES
134                    .iter()
135                    .map(|(n, _)| *n)
136                    .collect::<Vec<_>>()
137                    .join(", ")
138            ))
139        }),
140    }
141}
142
143/// An optional slot, for a key a configuration may leave out.
144pub fn deserialize_slot_opt<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
145where
146    D: Deserializer<'de>,
147{
148    #[derive(Deserialize)]
149    #[serde(untagged)]
150    enum Raw {
151        Number(i32),
152        Name(String),
153    }
154    match Option::<Raw>::deserialize(deserializer)? {
155        None => Ok(None),
156        Some(Raw::Number(n)) => Ok(Some(n)),
157        Some(Raw::Name(name)) => slot::parse(&name)
158            .map(Some)
159            .ok_or_else(|| serde::de::Error::custom(format!("unknown slot {name:?}"))),
160    }
161}
162
163/// Where a menu entry goes instead of the home its author gave it: another
164/// hierarchy, a band and an order there, and for the settings index the
165/// category heading it sits under.
166///
167/// ```toml
168/// [backend.menu]
169/// "backend.access_log" = { in = "backend.nav", slot = "tools" }
170/// "resource:/orders" = { in = "backend.settings", category = "Shop" }
171/// ```
172#[derive(Debug, Clone, Default, Deserialize)]
173#[serde(deny_unknown_fields, default)]
174#[non_exhaustive]
175pub struct Move {
176    /// `backend.nav`, `backend.nav.<section>` or `backend.settings`.
177    #[serde(rename = "in")]
178    pub hierarchy: String,
179    #[serde(deserialize_with = "deserialize_slot_opt")]
180    pub slot: Option<i32>,
181    pub order: Option<i32>,
182    /// The settings category heading; the band's name when left out.
183    pub category: Option<String>,
184}
185
186/// Whether a hierarchy's arrangement may be changed after the author placed
187/// its entries.
188#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
189pub enum Policy {
190    /// Rendered as declared; overrides are ignored and logged.
191    Fixed,
192    /// A deployment's `[backend.order]` and, when the hierarchy allows it, an
193    /// operator's preference rearrange it.
194    #[default]
195    Adjustable,
196}
197
198/// A list entries are arranged in: its key names it in configuration, its
199/// policy says who may rearrange it, and its pins hold the ends.
200#[derive(Clone, Debug)]
201#[non_exhaustive]
202pub struct Hierarchy {
203    /// `backend.nav`, `backend.settings`, or a plugin's own `vendor.name`.
204    pub key: String,
205    pub policy: Policy,
206    /// Whether an operator's own preference may rearrange it, beyond the
207    /// deployment's configuration.
208    pub operator_adjustable: bool,
209    /// Keys that stay first, in this order, whatever anyone says.
210    pub pinned_first: Vec<String>,
211    /// Keys that stay last, in this order.
212    pub pinned_last: Vec<String>,
213}
214
215impl Hierarchy {
216    pub fn new(key: impl Into<String>) -> Self {
217        Self {
218            key: key.into(),
219            policy: Policy::Adjustable,
220            operator_adjustable: false,
221            pinned_first: Vec::new(),
222            pinned_last: Vec::new(),
223        }
224    }
225
226    pub fn policy(mut self, policy: Policy) -> Self {
227        self.policy = policy;
228        self
229    }
230
231    /// Lets an operator's preference rearrange it.
232    pub fn operator_adjustable(mut self) -> Self {
233        self.operator_adjustable = true;
234        self
235    }
236
237    pub fn pin_first(mut self, key: impl Into<String>) -> Self {
238        self.pinned_first.push(key.into());
239        self
240    }
241
242    pub fn pin_last(mut self, key: impl Into<String>) -> Self {
243        self.pinned_last.push(key.into());
244        self
245    }
246
247    fn pinned(&self, key: &str) -> bool {
248        self.pinned_first.iter().any(|k| k == key) || self.pinned_last.iter().any(|k| k == key)
249    }
250}
251
252/// What an arranged entry exposes.
253pub trait Arranged {
254    /// Unique among its siblings: what an anchor or an override names.
255    fn key(&self) -> &str;
256    fn position(&self) -> &Position;
257    /// The tie-break after slot and order.
258    fn label(&self) -> &str;
259}
260
261/// The sequences that rearrange an adjustable hierarchy: the deployment's,
262/// then the operator's on top. Each lists keys in the wanted order; keys left
263/// out keep their arrangement after the listed ones, and a key that is no
264/// entry is skipped.
265#[derive(Clone, Debug, Default)]
266pub struct Overrides<'a> {
267    pub deployment: Option<&'a [String]>,
268    pub operator: Option<&'a [String]>,
269}
270
271#[derive(Debug, thiserror::Error, PartialEq, Eq)]
272pub enum ArrangeError {
273    #[error("`{key}` sits {relation} `{anchor}`, which is no sibling")]
274    UnknownAnchor {
275        key: String,
276        relation: &'static str,
277        anchor: String,
278    },
279}
280
281/// Arranges `items` for `hierarchy`: by slot, then order, then label; each
282/// anchored entry moved next to its anchor; then the overrides the policy
283/// admits; then the pins.
284pub fn arrange<T: Arranged>(
285    hierarchy: &Hierarchy,
286    mut items: Vec<T>,
287    overrides: Overrides<'_>,
288) -> Result<Vec<T>, ArrangeError> {
289    items.sort_by(|a, b| {
290        let (pa, pb) = (a.position(), b.position());
291        pa.slot
292            .cmp(&pb.slot)
293            .then_with(|| pa.order.cmp(&pb.order))
294            .then_with(|| a.label().cmp(b.label()))
295    });
296    anchor(&mut items)?;
297    if hierarchy.policy == Policy::Adjustable {
298        if let Some(sequence) = overrides.deployment {
299            reorder(hierarchy, &mut items, sequence);
300        }
301        if hierarchy.operator_adjustable {
302            if let Some(sequence) = overrides.operator {
303                reorder(hierarchy, &mut items, sequence);
304            }
305        }
306    } else if overrides.deployment.is_some() || overrides.operator.is_some() {
307        tracing::info!(hierarchy = %hierarchy.key, "arrangement is fixed; an override was ignored");
308    }
309    pin(hierarchy, &mut items);
310    Ok(items)
311}
312
313/// Moves each anchored entry next to its anchor, in order of appearance, so
314/// `after` holds whatever the slots say. An anchor that is no sibling is
315/// refused: a typo would otherwise be silent.
316fn anchor<T: Arranged>(items: &mut Vec<T>) -> Result<(), ArrangeError> {
317    let keys: Vec<String> = items.iter().map(|i| i.key().to_string()).collect();
318    for key in keys {
319        let Some(at) = items.iter().position(|i| i.key() == key) else {
320            continue;
321        };
322        let (anchor_key, after) = match (&items[at].position().after, &items[at].position().before)
323        {
324            (Some(a), _) => (a.clone(), true),
325            (None, Some(b)) => (b.clone(), false),
326            (None, None) => continue,
327        };
328        let Some(target) = items.iter().position(|i| i.key() == anchor_key) else {
329            return Err(ArrangeError::UnknownAnchor {
330                key,
331                relation: if after { "after" } else { "before" },
332                anchor: anchor_key,
333            });
334        };
335        let moved = items.remove(at);
336        let target = items
337            .iter()
338            .position(|i| i.key() == anchor_key)
339            .unwrap_or(target);
340        let to = if after { target + 1 } else { target };
341        items.insert(to.min(items.len()), moved);
342    }
343    Ok(())
344}
345
346/// Puts the keys in `sequence` first, as listed, then the rest as they were.
347/// Pinned keys are left for [`pin`]; a key that is no entry is skipped.
348pub fn reorder<T: Arranged>(hierarchy: &Hierarchy, items: &mut Vec<T>, sequence: &[String]) {
349    let mut rest: Vec<T> = std::mem::take(items);
350    let mut ordered: Vec<T> = Vec::with_capacity(rest.len());
351    for key in sequence {
352        if hierarchy.pinned(key) {
353            continue;
354        }
355        if let Some(at) = rest.iter().position(|i| i.key() == key) {
356            ordered.push(rest.remove(at));
357        }
358    }
359    ordered.extend(rest);
360    *items = ordered;
361}
362
363/// Moves the pinned keys to the ends, in the hierarchy's order.
364fn pin<T: Arranged>(hierarchy: &Hierarchy, items: &mut Vec<T>) {
365    let mut rest: Vec<T> = std::mem::take(items);
366    let mut first: Vec<T> = Vec::new();
367    for key in &hierarchy.pinned_first {
368        if let Some(at) = rest.iter().position(|i| i.key() == key) {
369            first.push(rest.remove(at));
370        }
371    }
372    let mut last: Vec<T> = Vec::new();
373    for key in &hierarchy.pinned_last {
374        if let Some(at) = rest.iter().position(|i| i.key() == key) {
375            last.push(rest.remove(at));
376        }
377    }
378    first.extend(rest);
379    first.extend(last);
380    *items = first;
381}
382
383/// A sequence as a preference or a config value stores it: comma-separated
384/// keys, blanks dropped.
385pub fn parse_sequence(text: &str) -> Vec<String> {
386    text.split(',')
387        .map(str::trim)
388        .filter(|k| !k.is_empty())
389        .map(str::to_string)
390        .collect()
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[derive(Debug, PartialEq)]
398    struct Entry {
399        key: &'static str,
400        label: &'static str,
401        position: Position,
402    }
403
404    impl Arranged for Entry {
405        fn key(&self) -> &str {
406            self.key
407        }
408        fn position(&self) -> &Position {
409            &self.position
410        }
411        fn label(&self) -> &str {
412            self.label
413        }
414    }
415
416    fn entry(key: &'static str, position: Position) -> Entry {
417        Entry {
418            key,
419            label: key,
420            position,
421        }
422    }
423
424    fn keys(items: &[Entry]) -> Vec<&str> {
425        items.iter().map(|e| e.key).collect()
426    }
427
428    fn menu() -> Hierarchy {
429        Hierarchy::new("backend.nav")
430            .pin_first("dashboard")
431            .pin_last("settings")
432            .operator_adjustable()
433    }
434
435    #[test]
436    fn slots_order_the_bands_and_names_parse() {
437        let items = vec![
438            entry("acme.shop", Position::new()),
439            entry("settings", Position::new().slot(slot::SYSTEM)),
440            entry("media", Position::new().slot(slot::MEDIA)),
441            entry("dashboard", Position::new().slot(slot::HOME)),
442            entry("acme.blog", Position::new().order(-1)),
443        ];
444        let out = arrange(&menu(), items, Overrides::default()).unwrap();
445        assert_eq!(
446            keys(&out),
447            ["dashboard", "media", "acme.blog", "acme.shop", "settings"]
448        );
449        assert_eq!(slot::parse("Media"), Some(slot::MEDIA));
450        assert_eq!(slot::parse("nope"), None);
451        assert_eq!(slot::name(slot::TOOLS), "tools");
452        let p: Position = serde_json::from_str(r#"{"slot":"people","order":2}"#).unwrap();
453        assert_eq!((p.slot, p.order), (slot::PEOPLE, 2));
454        let p: Position = serde_json::from_str(r#"{"slot":150}"#).unwrap();
455        assert_eq!(p.slot, 150);
456        assert!(serde_json::from_str::<Position>(r#"{"slot":"attic"}"#).is_err());
457    }
458
459    #[test]
460    fn an_anchor_holds_across_bands_and_a_missing_one_is_refused() {
461        let items = vec![
462            entry("media", Position::new().slot(slot::MEDIA)),
463            entry("acme.shop", Position::new()),
464            entry(
465                "acme.reports",
466                Position::new().slot(slot::TOOLS).before("media"),
467            ),
468        ];
469        let out = arrange(&Hierarchy::new("h"), items, Overrides::default()).unwrap();
470        assert_eq!(keys(&out), ["acme.reports", "media", "acme.shop"]);
471        let items = vec![entry("a", Position::new().after("ghost"))];
472        assert_eq!(
473            arrange(&Hierarchy::new("h"), items, Overrides::default()).unwrap_err(),
474            ArrangeError::UnknownAnchor {
475                key: "a".into(),
476                relation: "after",
477                anchor: "ghost".into()
478            }
479        );
480    }
481
482    #[test]
483    fn overrides_rearrange_an_adjustable_hierarchy_and_never_the_pins() {
484        let items = || {
485            vec![
486                entry("dashboard", Position::new().slot(slot::HOME)),
487                entry("media", Position::new().slot(slot::MEDIA)),
488                entry("acme.shop", Position::new()),
489                entry("acme.blog", Position::new()),
490                entry("settings", Position::new().slot(slot::SYSTEM)),
491            ]
492        };
493        let deployment = parse_sequence("acme.shop, media");
494        let operator = parse_sequence("settings,acme.blog,gone,dashboard");
495        let out = arrange(
496            &menu(),
497            items(),
498            Overrides {
499                deployment: Some(&deployment),
500                operator: None,
501            },
502        )
503        .unwrap();
504        assert_eq!(
505            keys(&out),
506            ["dashboard", "acme.shop", "media", "acme.blog", "settings"]
507        );
508        let out = arrange(
509            &menu(),
510            items(),
511            Overrides {
512                deployment: Some(&deployment),
513                operator: Some(&operator),
514            },
515        )
516        .unwrap();
517        assert_eq!(
518            keys(&out),
519            ["dashboard", "acme.blog", "acme.shop", "media", "settings"],
520            "the operator's order wins, the ends stay"
521        );
522        let fixed = Hierarchy::new("h").policy(Policy::Fixed);
523        let out = arrange(
524            &fixed,
525            items(),
526            Overrides {
527                deployment: Some(&deployment),
528                operator: Some(&operator),
529            },
530        )
531        .unwrap();
532        assert_eq!(
533            keys(&out),
534            ["dashboard", "media", "acme.blog", "acme.shop", "settings"],
535            "a fixed hierarchy ignores every override"
536        );
537        let out = arrange(
538            &Hierarchy::new("h"),
539            items(),
540            Overrides {
541                deployment: None,
542                operator: Some(&operator),
543            },
544        )
545        .unwrap();
546        assert_eq!(
547            keys(&out),
548            ["dashboard", "media", "acme.blog", "acme.shop", "settings"],
549            "an operator's order needs the hierarchy to allow it"
550        );
551    }
552}